diff --git a/Cargo.lock b/Cargo.lock index 9f532c48f45..1780387a1b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3766,6 +3766,19 @@ dependencies = [ "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]] name = "graphql-introspection-query" version = "0.2.0" @@ -4267,6 +4280,7 @@ dependencies = [ "ensogl-hardcoded-theme", "ensogl-text", "ensogl-text-msdf", + "gloo-utils", "ide-view-component-browser", "ide-view-documentation", "ide-view-execution-environment-selector", @@ -4280,8 +4294,10 @@ dependencies = [ "serde", "serde_json", "span-tree", + "strum", "uuid 0.8.2", "wasm-bindgen", + "wasm-bindgen-test", "web-sys", "welcome-screen", ] @@ -7386,8 +7402,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" dependencies = [ "cfg-if 1.0.0", - "serde", - "serde_json", "wasm-bindgen-macro", ] diff --git a/Cargo.toml b/Cargo.toml index b98d5d220a1..5edf201d4e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ debug-assertions = true console-subscriber = "0.1.8" nix = "0.26.1" octocrab = { git = "https://github.com/enso-org/octocrab", default-features = false, features = [ - "rustls" + "rustls", ] } regex = { version = "1.6.0" } 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-stream = { version = "0.1.12", features = ["fs"] } 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" } anyhow = { version = "1.0.66" } failure = { version = "0.1.8" } @@ -135,7 +135,11 @@ syn = { version = "1.0", features = [ ] } quote = { version = "1.0.23" } semver = { version = "1.0.0", features = ["serde"] } +strum = { version = "0.24.0", features = ["derive"] } thiserror = "1.0.40" bytemuck = { version = "1.13.1", features = ["derive"] } bitflags = { version = "2.2.1" } 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" } diff --git a/app/gui/controller/engine-protocol/Cargo.toml b/app/gui/controller/engine-protocol/Cargo.toml index 1c3653c10fb..fc86a57bdda 100644 --- a/app/gui/controller/engine-protocol/Cargo.toml +++ b/app/gui/controller/engine-protocol/Cargo.toml @@ -22,7 +22,7 @@ mockall = { version = "0.7.1", features = ["nightly"] } serde = { version = "1.0", features = ["derive"] } serde_json = { workspace = true } sha3 = { version = "0.8.2" } -strum = "0.24.0" +strum = { workspace = true } strum_macros = "0.24.0" uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] } diff --git a/app/gui/view/Cargo.toml b/app/gui/view/Cargo.toml index f247685454f..8f5822ddabe 100644 --- a/app/gui/view/Cargo.toml +++ b/app/gui/view/Cargo.toml @@ -28,14 +28,17 @@ ide-view-documentation = { path = "documentation" } ide-view-graph-editor = { path = "graph-editor" } ide-view-project-view-top-bar = { path = "project-view-top-bar" } span-tree = { path = "../language/span-tree" } +gloo-utils = { workspace = true } js-sys = { workspace = true } multi-map = { workspace = true } nalgebra = { workspace = true } ordered-float = { workspace = true } serde_json = { workspace = true } serde = { version = "1.0", features = ["derive"] } +strum = { workspace = true } uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] } wasm-bindgen = { workspace = true } +wasm-bindgen-test = { workspace = true } welcome-screen = { path = "welcome-screen" } [dependencies.web-sys] diff --git a/app/gui/view/src/lib.rs b/app/gui/view/src/lib.rs index 2e2064c3dde..92be1334e52 100644 --- a/app/gui/view/src/lib.rs +++ b/app/gui/view/src/lib.rs @@ -32,6 +32,7 @@ #[allow(clippy::option_map_unit_fn)] pub mod code_editor; pub mod debug_mode_popup; +pub mod notification; pub mod popup; pub mod project; pub mod project_list; diff --git a/app/gui/view/src/notification.rs b/app/gui/view/src/notification.rs new file mode 100644 index 00000000000..b14c4cc5e79 --- /dev/null +++ b/app/gui/view/src/notification.rs @@ -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) -> Result { + 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) -> Result { + send_any(message, Type::Info, options) +} + +/// Send a warning notification. +pub fn warning(message: &str, options: &Option) -> Result { + send_any(message, Type::Warning, options) +} + +/// Send a error notification. +pub fn error(message: &str, options: &Option) -> Result { + send_any(message, Type::Error, options) +} + +/// Send a success notification. +pub fn success(message: &str, options: &Option) -> Result { + 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 for type` +macro_rules! impl_try_from_jsvalue { + ($type:ty) => { + impl TryFrom<&$type> for JsValue { + type Error = JsValue; + + fn try_from(value: &$type) -> Result { + JsValue::from_serde(value).map_err($crate::notification::to_js_error) + } + } + + impl TryFrom for $type { + type Error = JsValue; + + fn try_from(js_value: JsValue) -> Result { + 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(&self, serializer: S) -> Result { + 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::(&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>(deserializer: D) -> Result { + 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, + /// Type of the notification, affecting styling and icon. Default: `Type::Default`. + pub r#type: Option, + /// The position where the toast should appear. Default: `bottom-right`. + pub position: Option, + /// Time in milliseconds after the toast is removed. Default: `5000`. + pub auto_close: Option, + /// Whether to show a close button. Default: `true`. + pub close_button: Option, + // pub transition: Option, + /// Hide or show the progress bar. `Default: false` + pub hide_progress_bar: Option, + /// Pause the timer when the mouse hover the toast. Default: `true`. + pub pause_on_hover: Option, + /// Pause the timer when the window loses focus. Default: `true`. + pub pause_on_focus_loss: Option, + /// Remove the toast when clicked. Default: `true`. + pub close_on_click: Option, + /// An optional css class to set. + pub class_name: Option, + /// An optional css class to set for the toast content. + pub body_class_name: Option, + /// An optional inline style to apply. This should be a JS object with CSS properties. + pub style: Option, + /// An optional inline style to apply for the toast content. This should be a JS object with + /// CSS properties. + pub body_style: Option, + /// An optional css class to set for the progress bar. + pub progress_class_name: Option, + /// An optional inline style to apply for the progress bar. This should be a JS object with + /// CSS properties. + pub progress_style: Option, + /// Allow toast to be draggable. `Default: true`. + pub draggable: Option, + /// The percentage of the toast's width it takes for a drag to dismiss a toast. `Default: 80`. + pub draggable_percent: Option, + /// Specify in which direction should you swipe to dismiss the toast. `Default: "x"`. + pub draggable_direction: Option, + /// Set id to handle multiple `ToastContainer` instances. + pub container_id: Option, + /// Define the [ARIA role](https://www.w3.org/WAI/PF/aria/roles) for the notification. `Default: "alert"`. + pub role: Option, + /// Add a delay in ms before the toast appear. + pub delay: Option, + /// 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, + /// Support right to left display content. `Default: false`. + pub rtl: Option, + /// Theme to use. + pub theme: Option, + /// Used to display a custom icon. Set it to `false` to prevent the icons from being displayed + pub icon: Option, + /// Any additional data to pass to the toast. + pub data: Option, +} + +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, +} + +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) -> 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::().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"); + } +} diff --git a/app/gui/view/src/notification/js.rs b/app/gui/view/src/notification/js.rs new file mode 100644 index 00000000000..2a3995ab9dc --- /dev/null +++ b/app/gui/view/src/notification/js.rs @@ -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 { + // 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; + + /// 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; + + /// 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; + + /// 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 { + 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 { + 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 { + let method: &str = r#type.as_ref(); + get_toast()?.send(message, method, options) +} diff --git a/app/ide-desktop/lib/content/package.json b/app/ide-desktop/lib/content/package.json index 65b5cc017d7..69345e7491d 100644 --- a/app/ide-desktop/lib/content/package.json +++ b/app/ide-desktop/lib/content/package.json @@ -23,7 +23,8 @@ }, "dependencies": { "@types/semver": "^7.3.9", - "enso-content-config": "^1.0.0" + "enso-content-config": "^1.0.0", + "react-toastify": "^9.1.3" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index a7a9d743fbb..7f7ce51d42a 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -3,6 +3,7 @@ * allowing to choose a debug rendering test from. */ import * as semver from 'semver' +import * as toastify from 'react-toastify' import * as common from 'enso-common' import * as contentConfig from 'enso-content-config' @@ -152,6 +153,7 @@ interface AuthenticationConfig { /** Contains the entrypoint into the IDE. */ class Main implements AppRunner { app: app.App | null = null + toast = toastify.toast /** Stop an app instance, if one is running. */ stopApp() { diff --git a/app/ide-desktop/lib/dashboard/esbuild-config.ts b/app/ide-desktop/lib/dashboard/esbuild-config.ts index e929b7ed4b6..944fd7ea54c 100644 --- a/app/ide-desktop/lib/dashboard/esbuild-config.ts +++ b/app/ide-desktop/lib/dashboard/esbuild-config.ts @@ -75,7 +75,7 @@ function esbuildPluginGenerateTailwind(): esbuild.Plugin { 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 let output = cachedOutput[loadArgs.path] if (!output || output.lastModified !== lastModified || tailwindConfigWasModified) { diff --git a/app/ide-desktop/lib/dashboard/package.json b/app/ide-desktop/lib/dashboard/package.json index c098dc61dbb..20086829779 100644 --- a/app/ide-desktop/lib/dashboard/package.json +++ b/app/ide-desktop/lib/dashboard/package.json @@ -31,6 +31,7 @@ "eslint": "^8.32.0", "eslint-plugin-jsdoc": "^39.6.8", "eslint-plugin-react": "^7.32.1", + "react-toastify": "^9.1.3", "tailwindcss": "^3.2.7", "typescript": "^4.9.4" }, diff --git a/app/ide-desktop/lib/dashboard/src/authentication/package.json b/app/ide-desktop/lib/dashboard/src/authentication/package.json index 705632f2924..2fc774a9ce5 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/package.json +++ b/app/ide-desktop/lib/dashboard/src/authentication/package.json @@ -16,7 +16,7 @@ "@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "enso-common": "^1.0.0", - "react-hot-toast": "^2.4.0", + "react-toastify": "^9.1.3", "react-router-dom": "^6.8.1", "ts-results": "^3.3.0" }, diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx index 1ded0b2f267..48e1104c114 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx @@ -2,7 +2,7 @@ * email address. */ import * as React from 'react' 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 authModule from '../providers/auth' @@ -41,7 +41,7 @@ function ConfirmRegistration() { navigate(app.LOGIN_PATH + location.search.toString()) } catch (error) { logger.error('Error while confirming sign-up', error) - toast.error( + toastify.toast.error( 'Something went wrong! Please try again or contact the administrators.' ) navigate(app.LOGIN_PATH) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx index 05d7ef97ca5..bd3d3fe2195 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx @@ -1,7 +1,7 @@ /** @file Registration container responsible for rendering and interactions in sign up flow. */ import * as React from 'react' 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 CreateAccountIcon from 'enso-assets/create_account.svg' @@ -41,7 +41,7 @@ function Registration() { const onSubmit = () => { /** The password & confirm password fields must match. */ if (password !== confirmPassword) { - toast.error('Passwords do not match.') + toastify.toast.error('Passwords do not match.') return Promise.resolve() } else { return auth.signUp(email, password, organizationId) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx index 4d7f31db8fe..cc3c05d13a7 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx @@ -2,7 +2,7 @@ * flow. */ import * as React from 'react' 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 AtIcon from 'enso-assets/at.svg' @@ -44,7 +44,7 @@ function ResetPassword() { const onSubmit = () => { if (newPassword !== newPasswordConfirm) { - toast.error('Passwords do not match') + toastify.toast.error('Passwords do not match') return Promise.resolve() } else { return resetPassword(email, code, newPassword) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index f13f1b907d9..ebb3db49f88 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -5,7 +5,7 @@ * hook also provides methods for registering a user, logging in, logging out, etc. */ import * as React from 'react' 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 authServiceModule from '../service' @@ -211,7 +211,7 @@ export function AuthProvider(props: AuthProviderProps) { const goOffline = React.useCallback( (shouldShowToast = true) => { if (shouldShowToast) { - toast.error('You are offline, switching to offline mode.') + toastify.toast.error('You are offline, switching to offline mode.') } goOfflineInternal() navigate(app.DASHBOARD_PATH) @@ -311,7 +311,7 @@ export function AuthProvider(props: AuthProviderProps) { fetchSession().catch(error => { if (isUserFacingError(error)) { - toast.error(error.message) + toastify.toast.error(error.message) logger.error(error.message) } else { logger.error(error) @@ -336,12 +336,12 @@ export function AuthProvider(props: AuthProviderProps) { const withLoadingToast = (action: (...args: T) => Promise) => async (...args: T) => { - const loadingToast = toast.loading(MESSAGES.pleaseWait) + const loadingToast = toastify.toast.loading(MESSAGES.pleaseWait) let result try { result = await action(...args) } finally { - toast.dismiss(loadingToast) + toastify.toast.dismiss(loadingToast) } return result } @@ -349,10 +349,10 @@ export function AuthProvider(props: AuthProviderProps) { const signUp = async (username: string, password: string, organizationId: string | null) => { const result = await cognito.signUp(username, password, organizationId) if (result.ok) { - toast.success(MESSAGES.signUpSuccess) + toastify.toast.success(MESSAGES.signUpSuccess) navigate(app.LOGIN_PATH) } else { - toast.error(result.val.message) + toastify.toast.error(result.val.message) } 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) return result.ok } @@ -376,13 +376,13 @@ export function AuthProvider(props: AuthProviderProps) { const signInWithPassword = async (email: string, password: string) => { const result = await cognito.signInWithPassword(email, password) if (result.ok) { - toast.success(MESSAGES.signInWithPasswordSuccess) + toastify.toast.success(MESSAGES.signInWithPasswordSuccess) } else { if (result.val.kind === 'UserNotFound') { navigate(app.REGISTRATION_PATH) } - toast.error(result.val.message) + toastify.toast.error(result.val.message) } return result.ok } @@ -393,14 +393,14 @@ export function AuthProvider(props: AuthProviderProps) { email: string ) => { 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 } else { try { const organizationId = await authService.cognito.organizationId() // This should not omit success and error toasts as it is not possible // to render this optimistically. - await toast.promise( + await toastify.toast.promise( backend.createUser({ userName: username, userEmail: backendModule.EmailAddress(email), @@ -412,7 +412,7 @@ export function AuthProvider(props: AuthProviderProps) { { success: MESSAGES.setUsernameSuccess, error: MESSAGES.setUsernameFailure, - loading: MESSAGES.setUsernameLoading, + pending: MESSAGES.setUsernameLoading, } ) navigate(app.DASHBOARD_PATH) @@ -426,10 +426,10 @@ export function AuthProvider(props: AuthProviderProps) { const forgotPassword = async (email: string) => { const result = await cognito.forgotPassword(email) if (result.ok) { - toast.success(MESSAGES.forgotPasswordSuccess) + toastify.toast.success(MESSAGES.forgotPasswordSuccess) navigate(app.RESET_PASSWORD_PATH) } else { - toast.error(result.val.message) + toastify.toast.error(result.val.message) } return result.ok } @@ -437,10 +437,10 @@ export function AuthProvider(props: AuthProviderProps) { const resetPassword = async (email: string, code: string, password: string) => { const result = await cognito.forgotPasswordSubmit(email, code, password) if (result.ok) { - toast.success(MESSAGES.resetPasswordSuccess) + toastify.toast.success(MESSAGES.resetPasswordSuccess) navigate(app.LOGIN_PATH) } else { - toast.error(result.val.message) + toastify.toast.error(result.val.message) } return result.ok } @@ -448,9 +448,9 @@ export function AuthProvider(props: AuthProviderProps) { const changePassword = async (oldPassword: string, newPassword: string) => { const result = await cognito.changePassword(oldPassword, newPassword) if (result.ok) { - toast.success(MESSAGES.changePasswordSuccess) + toastify.toast.success(MESSAGES.changePasswordSuccess) } else { - toast.error(result.val.message) + toastify.toast.error(result.val.message) } return result.ok } @@ -461,10 +461,10 @@ export function AuthProvider(props: AuthProviderProps) { setUserSession(null) // This should not omit success and error toasts as it is not possible // to render this optimistically. - await toast.promise(cognito.signOut(), { + await toastify.toast.promise(cognito.signOut(), { success: MESSAGES.signOutSuccess, error: MESSAGES.signOutError, - loading: MESSAGES.signOutLoading, + pending: MESSAGES.signOutLoading, }) return true } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index 02e20f3c142..b1863798a84 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -36,7 +36,7 @@ import * as React from 'react' 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' @@ -131,7 +131,7 @@ function App(props: AppProps) { * will redirect the user between the login/register pages and the dashboard. */ return ( <> - + diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 534765f194c..0581c2c4b33 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -1,6 +1,6 @@ /** @file Managing the logic and displaying the UI for the password change function. */ 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 LockIcon from 'enso-assets/lock.svg' @@ -29,7 +29,7 @@ function ChangePasswordModal() { const onSubmit = async () => { if (newPassword !== confirmNewPassword) { - toast.error('Passwords do not match.') + toastify.toast.error('Passwords do not match.') } else { const success = await changePassword(oldPassword, newPassword) if (success) { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx index 57f10e8b3a0..9fe7c4d4014 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx @@ -1,7 +1,7 @@ /** @file A WebSocket-based chat directly to official support on the official Discord server. */ import * as React from 'react' 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 DefaultUserIcon from 'enso-assets/default_user.svg' @@ -611,7 +611,7 @@ function Chat(props: ChatProps) { const threadData = threads.find(thread => thread.id === newThreadId) if (threadData == null) { const message = `Unknown thread id '${newThreadId}'.` - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { sendMessage({ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx index 6b3b912a0cb..872f9fdaec4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -1,6 +1,6 @@ /** @file Modal for confirming delete of any type of asset. */ import * as React from 'react' -import toast from 'react-hot-toast' +import * as toastify from 'react-toastify' import CloseIcon from 'enso-assets/close.svg' @@ -32,10 +32,8 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { try { doDelete() } catch (error) { - const message = `Could not delete ${description}: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) + const message = errorModule.getMessageOrToString(error) + toastify.toast.error(message) logger.error(message) } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoriesTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoriesTable.tsx index a015e4becd6..9176cfbab7e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoriesTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoriesTable.tsx @@ -1,6 +1,6 @@ /** @file Table displaying a list of directories. */ 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 PlusIcon from 'enso-assets/plus.svg' @@ -111,7 +111,6 @@ function DirectoryName(props: InternalDirectoryNameProps) { rowState, setRowState, } = props - const logger = loggerProvider.useLogger() const { backend } = backendProvider.useBackend() const doRename = async (newName: string) => { @@ -120,11 +119,7 @@ function DirectoryName(props: InternalDirectoryNameProps) { await backend.updateDirectory(item.id, { title: newName }, item.title) return } catch (error) { - const message = `Error renaming folder: ${ - errorModule.tryGetMessage(error) ?? 'unknown error' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Error renaming folder', error) throw error } } @@ -274,11 +269,7 @@ function DirectoryRow( } catch (error) { setStatus(presence.Presence.present) markItemAsVisible(key) - const message = `Unable to delete directory: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Unable to delete directory', error) } } } @@ -289,7 +280,7 @@ function DirectoryRow( if (key === event.placeholderId) { if (backend.type !== backendModule.BackendType.remote) { const message = 'Folders cannot be created on the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { setStatus(presence.Presence.inserting) @@ -309,11 +300,7 @@ function DirectoryRow( type: directoryListEventModule.DirectoryListEventType.delete, directoryId: key, }) - const message = `Error creating new folder: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Error creating new folder', error) } } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryView.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryView.tsx index 2bee507a40d..bb599336746 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryView.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryView.tsx @@ -1,6 +1,6 @@ /** @file The directory header bar and directory item listing. */ import * as React from 'react' -import toast from 'react-hot-toast' +import * as toastify from 'react-toastify' import * as common from 'enso-common' @@ -228,7 +228,7 @@ function DirectoryView(props: DirectoryViewProps) { setInitialized(true) if (!newProjectAssets.some(asset => asset.title === initialProjectName)) { const errorMessage = `No project named '${initialProjectName}' was found.` - toast.error(errorMessage) + toastify.toast.error(errorMessage) logger.error(`Error opening project on startup: ${errorMessage}`) } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx index 91348ce1664..cd87c4ea604 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx @@ -1,7 +1,7 @@ /** @file Header menubar for the directory listing, containing information about * the current directory and some configuration options. */ 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 DownloadIcon from 'enso-assets/download.svg' @@ -52,18 +52,18 @@ function DriveBar(props: DriveBarProps) { // TODO[sb]: Allow uploading `.enso-project`s // https://github.com/enso-org/cloud-v2/issues/510 const message = 'Files cannot be uploaded to the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else if ( event.currentTarget.files == null || event.currentTarget.files.length === 0 ) { - toast.success('No files selected to upload.') + toastify.toast.success('No files selected to upload.') } else if (directoryId == null) { // This should never happen, however display a nice error message in case // it somehow does. const message = 'Files cannot be uploaded while offline.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { dispatchFileListEvent({ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/filesTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/filesTable.tsx index c62c1e06386..a54c6797e4e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/filesTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/filesTable.tsx @@ -1,6 +1,6 @@ /** @file Table displaying a list of files. */ import * as React from 'react' -import toast from 'react-hot-toast' +import * as toastify from 'react-toastify' import PlusIcon from 'enso-assets/plus.svg' @@ -76,18 +76,18 @@ function FileNameHeading(props: InternalFileNameHeadingProps) { // TODO[sb]: Allow uploading `.enso-project`s // https://github.com/enso-org/cloud-v2/issues/510 const message = 'Files cannot be uploaded to the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else if ( event.currentTarget.files == null || event.currentTarget.files.length === 0 ) { - toast.success('No files selected to upload.') + toastify.toast.success('No files selected to upload.') } else if (directoryId == null) { // This should never happen, however display a nice error message in case // it somehow does. const message = 'Files cannot be uploaded while offline.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { dispatchFileListEvent({ @@ -284,11 +284,7 @@ function FileRow( } catch (error) { setStatus(presence.Presence.present) markItemAsVisible(key) - const message = `Unable to delete file: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Unable to delete file', error) } } } @@ -300,7 +296,7 @@ function FileRow( if (file != null) { if (backend.type !== backendModule.BackendType.remote) { const message = 'Files cannot be uploaded on the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { setStatus(presence.Presence.inserting) @@ -324,11 +320,7 @@ function FileRow( type: fileListEventModule.FileListEventType.delete, fileId: key, }) - const message = `Error creating new file: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Error creating new file', error) } } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx index f1e9d1ed766..8570ec7bb3c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx @@ -1,6 +1,6 @@ /** @file A modal with inputs for user email and permission level. */ import * as React from 'react' -import toast from 'react-hot-toast' +import * as toastify from 'react-toastify' import CloseIcon from 'enso-assets/close.svg' @@ -234,7 +234,7 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) { userEmail: backendModule.EmailAddress(firstEmail), }) } catch (error) { - toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.') + toastify.toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.') } } else if (finalUsers.length !== 0) { unsetModal() @@ -250,7 +250,9 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) { } catch { onFailure?.(finalUsers, permissionsArray) 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(', ')}.` + ) } } }, diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectsTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectsTable.tsx index f7c277c7a3c..69f44f35a83 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectsTable.tsx @@ -1,6 +1,6 @@ /** @file Table displaying a list of projects. */ 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 PlayIcon from 'enso-assets/play.svg' @@ -14,7 +14,6 @@ import * as dateTime from '../dateTime' import * as errorModule from '../../error' import * as eventModule from '../event' import * as hooks from '../../hooks' -import * as loggerProvider from '../../providers/logger' import * as modalProvider from '../../providers/modal' import * as permissions from '../permissions' import * as presence from '../presence' @@ -157,14 +156,14 @@ function ProjectActionButton(props: ProjectActionButtonProps) { ((state: spinner.SpinnerState | null) => void) | null >(null) const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false) - const [toastId, setToastId] = React.useState(null) + const [toastId, setToastId] = React.useState(null) const openProject = React.useCallback(async () => { setState(backendModule.ProjectState.openInProgress) try { switch (backend.type) { case backendModule.BackendType.remote: - setToastId(toast.loading(LOADING_MESSAGE)) + setToastId(toastify.toast.loading(LOADING_MESSAGE)) setRowState({ ...rowState, isRunning: true }) await backend.openProject(project.id, null, project.title) setCheckState(CheckState.checkingStatus) @@ -183,7 +182,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) { } } catch (error) { setCheckState(CheckState.notChecking) - toast.error( + toastify.toast.error( `Error opening project '${project.title}': ${ errorModule.tryGetMessage(error) ?? 'unknown error' }.` @@ -195,7 +194,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) { React.useEffect(() => { if (toastId != null) { return () => { - toast.dismiss(toastId) + toastify.toast.dismiss(toastId) } } else { return @@ -222,7 +221,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) { React.useEffect(() => { if (toastId != null && state !== backendModule.ProjectState.openInProgress) { - toast.dismiss(toastId) + toastify.toast.dismiss(toastId) } }, [state, toastId]) @@ -501,7 +500,6 @@ function ProjectName(props: InternalProjectNameProps) { doCloseIde, }, } = props - const logger = loggerProvider.useLogger() const { backend } = backendProvider.useBackend() const doRename = async (newName: string) => { @@ -517,11 +515,7 @@ function ProjectName(props: InternalProjectNameProps) { ) return } catch (error) { - const message = `Unable to rename project: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Unable to rename project', error) throw error } } @@ -701,7 +695,6 @@ function ProjectRow( markItemAsVisible, }, } = props - const logger = loggerProvider.useLogger() const { backend } = backendProvider.useBackend() const { setModal } = modalProvider.useSetModal() const [item, setItem] = React.useState(rawItem) @@ -723,9 +716,7 @@ function ProjectRow( } catch (error) { setStatus(presence.Presence.present) markItemAsVisible(key) - const message = errorModule.tryGetMessage(error) ?? 'Unable to delete project.' - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Unable to delete project', error) } } @@ -764,11 +755,7 @@ function ProjectRow( type: projectListEventModule.ProjectListEventType.delete, projectId: key, }) - const message = `Error creating new project: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Error creating new project', error) } } break diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretsTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretsTable.tsx index 21275f7e22c..651b1dce1f0 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretsTable.tsx @@ -1,6 +1,6 @@ /** @file Table displaying a list of secrets. */ 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 SecretIcon from 'enso-assets/secret.svg' @@ -79,10 +79,10 @@ function SecretCreateForm(props: InternalSecretCreateFormProps) { const onSubmit = (event: React.FormEvent) => { event.preventDefault() if (name == null) { - toast.error('Please provide a secret name.') + toastify.toast.error('Please provide a secret name.') } else if (value == null) { // Secret value explicitly can be empty. - toast.error('Please provide a secret value.') + toastify.toast.error('Please provide a secret value.') } else { unsetModal() dispatchSecretListEvent({ @@ -322,11 +322,7 @@ function SecretRow( } catch (error) { setStatus(presence.Presence.present) markItemAsVisible(key) - const message = `Unable to delete secret: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Unable to delete secret', error) } } } @@ -337,7 +333,7 @@ function SecretRow( if (key === event.placeholderId) { if (backend.type !== backendModule.BackendType.remote) { const message = 'Secrets cannot be created on the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { setStatus(presence.Presence.inserting) @@ -358,11 +354,7 @@ function SecretRow( type: secretListEventModule.SecretListEventType.delete, secretId: key, }) - const message = `Error creating new secret: ${ - errorModule.tryGetMessage(error) ?? 'unknown error.' - }` - toast.error(message) - logger.error(message) + errorModule.toastAndLog('Error creating new secret', error) } } } @@ -492,7 +484,7 @@ function SecretsTable(props: SecretsTableProps) { case secretListEventModule.SecretListEventType.create: { if (backend.type !== backendModule.BackendType.remote) { const message = 'Secrets cannot be created on the local backend.' - toast.error(message) + toastify.toast.error(message) logger.error(message) } else { const placeholderItem: backendModule.SecretAsset = { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts index 1a820a05131..0b7c2feb77c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts @@ -4,6 +4,10 @@ // === 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}. */ type MustBe = (() => U extends T ? 1 : 2) extends () => U extends Expected ? 1 @@ -29,6 +33,37 @@ export function tryGetMessage(error: unknown): string | null { : null } +/** Like {@link tryGetMessage} but return the string representation of the value if it is not an + * {@link Error} */ +export function getMessageOrToString(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 === // ============================ diff --git a/app/ide-desktop/lib/dashboard/src/tailwind.css b/app/ide-desktop/lib/dashboard/src/tailwind.css index 42fce40ab0f..88fe4db928a 100644 --- a/app/ide-desktop/lib/dashboard/src/tailwind.css +++ b/app/ide-desktop/lib/dashboard/src/tailwind.css @@ -1,4 +1,5 @@ @import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;700&display=swap"); +@import "react-toastify/dist/ReactToastify.css"; body { margin: 0; diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index 3941b29f562..763e80abb66 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -387,7 +387,8 @@ "version": "1.0.0", "dependencies": { "@types/semver": "^7.3.9", - "enso-content-config": "^1.0.0" + "enso-content-config": "^1.0.0", + "react-toastify": "^9.1.3" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", @@ -443,11 +444,12 @@ "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.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", "eslint": "^8.32.0", "eslint-plugin-jsdoc": "^39.6.8", "eslint-plugin-react": "^7.32.1", + "react-toastify": "^9.1.3", "tailwindcss": "^3.2.7", "typescript": "^4.9.4" }, @@ -525,8 +527,8 @@ "@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "enso-common": "^1.0.0", - "react-hot-toast": "^2.4.0", "react-router-dom": "^6.8.1", + "react-toastify": "^9.1.3", "ts-results": "^3.3.0" }, "devDependencies": { @@ -6818,6 +6820,14 @@ "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": { "version": "1.0.0", "license": "MIT", @@ -9283,13 +9293,6 @@ "node": ">=8" } }, - "node_modules/goober": { - "version": "2.1.12", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/gopd": { "version": "1.0.1", "dev": true, @@ -13501,20 +13504,6 @@ "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": { "version": "17.0.2", "license": "MIT", @@ -13689,6 +13678,18 @@ "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": { "version": "1.0.0", "dev": true, @@ -20625,6 +20626,11 @@ "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": { "version": "1.0.0", "peer": true, @@ -21455,8 +21461,8 @@ "@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "enso-common": "^1.0.0", - "react-hot-toast": "^2.4.0", "react-router-dom": "^6.8.1", + "react-toastify": "^9.1.3", "ts-results": "^3.3.0", "typescript": "^4.9.3" } @@ -21503,6 +21509,7 @@ "eslint-plugin-jsdoc": "^40.0.2", "globals": "^13.20.0", "portfinder": "^1.0.32", + "react-toastify": "^9.1.3", "tsx": "^3.12.6", "typescript": "^4.9.3" } @@ -21524,7 +21531,7 @@ "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.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", "esbuild": "^0.17.15", "esbuild-plugin-time": "^1.0.0", @@ -21534,6 +21541,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.7.0", + "react-toastify": "^9.1.3", "tailwindcss": "^3.2.7", "typescript": "^4.9.4" }, @@ -22624,10 +22632,6 @@ } } }, - "goober": { - "version": "2.1.12", - "requires": {} - }, "gopd": { "version": "1.0.1", "dev": true, @@ -25408,12 +25412,6 @@ "scheduler": "^0.23.0" } }, - "react-hot-toast": { - "version": "2.4.0", - "requires": { - "goober": "^2.1.10" - } - }, "react-is": { "version": "17.0.2", "peer": true @@ -25540,6 +25538,14 @@ "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": { "version": "1.0.0", "dev": true, diff --git a/build/build/Cargo.toml b/build/build/Cargo.toml index f6c2bc623d7..7aef6bf6481 100644 --- a/build/build/Cargo.toml +++ b/build/build/Cargo.toml @@ -63,7 +63,7 @@ serde = { version = "1.0.130", features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } scopeguard = "1.1.0" -strum = { version = "0.24.0", features = ["derive"] } +strum = { workspace = true } sysinfo = "0.26.2" tar = "0.4.37" tempfile = "3.2.0" diff --git a/build/ci_utils/Cargo.toml b/build/ci_utils/Cargo.toml index 1f660d77236..1ea62a1fe74 100644 --- a/build/ci_utils/Cargo.toml +++ b/build/ci_utils/Cargo.toml @@ -66,7 +66,7 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } scopeguard = "1.1.0" sha2 = "0.10.2" -strum = { version = "0.24.0", features = ["derive"] } +strum = { workspace = true } symlink = "0.1.0" syn = { workspace = true } sysinfo = "0.26.2" diff --git a/build/cli/Cargo.toml b/build/cli/Cargo.toml index 27e4167e306..af0b179294b 100644 --- a/build/cli/Cargo.toml +++ b/build/cli/Cargo.toml @@ -23,7 +23,7 @@ octocrab = { workspace = true } serde = { version = "1.0.130", features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } -strum = { version = "0.24.0", features = ["derive"] } +strum = { workspace = true } tempfile = "3.2.0" tokio = { workspace = true } toml = "0.5.9"