From cc678e86483535489192f3ff3165aecb459a9418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Brand=C3=A9ho?= Date: Thu, 29 Feb 2024 19:01:12 +0100 Subject: [PATCH] add pulser for connection + adjust condition for connnection_modal (#2676) * add pulser for connection + adjust condition for connnection_modal * update style for connection pulser * rename connectError to connectErrors * resolve update bug of connectErrors * fix pulse definition * rollback pulse definition * Define WifiOffPulse icon as its own component Attach the pulse keyframes and imports to the icon itself so that the code gets properly included in stateful_components.js when it is used. * limit number of errors in memory --------- Co-authored-by: Masen Furer --- .../jinja/web/utils/context.js.jinja2 | 4 +- reflex/.templates/web/utils/state.js | 333 ++++++++++-------- reflex/app.py | 27 +- reflex/components/core/__init__.py | 3 +- reflex/components/core/banner.py | 110 +++++- reflex/components/core/banner.pyi | 217 +++++++++++- reflex/constants/compiler.py | 3 +- 7 files changed, 521 insertions(+), 176 deletions(-) diff --git a/reflex/.templates/jinja/web/utils/context.js.jinja2 b/reflex/.templates/jinja/web/utils/context.js.jinja2 index 369b65136..95e9fb3ab 100644 --- a/reflex/.templates/jinja/web/utils/context.js.jinja2 +++ b/reflex/.templates/jinja/web/utils/context.js.jinja2 @@ -80,13 +80,13 @@ export function UploadFilesProvider({ children }) { export function EventLoopProvider({ children }) { const dispatch = useContext(DispatchContext) - const [addEvents, connectError] = useEventLoop( + const [addEvents, connectErrors] = useEventLoop( dispatch, initialEvents, clientStorage, ) return ( - + {children} ) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 13da2c510..f0051e582 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -6,14 +6,19 @@ import env from "/env.json"; import Cookies from "universal-cookie"; import { useEffect, useReducer, useRef, useState } from "react"; import Router, { useRouter } from "next/router"; -import { initialEvents, initialState, onLoadInternalEvent, state_name } from "utils/context.js" +import { + initialEvents, + initialState, + onLoadInternalEvent, + state_name, +} from "utils/context.js"; // Endpoint URLs. -const EVENTURL = env.EVENT -const UPLOADURL = env.UPLOAD +const EVENTURL = env.EVENT; +const UPLOADURL = env.UPLOAD; // These hostnames indicate that the backend and frontend are reachable via the same domain. -const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"] +const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"]; // Global variable to hold the token. let token; @@ -28,7 +33,7 @@ const cookies = new Cookies(); export const refs = {}; // Flag ensures that only one event is processing on the backend concurrently. -let event_processing = false +let event_processing = false; // Array holding pending events to be processed. const event_queue = []; @@ -64,7 +69,7 @@ export const getToken = () => { if (token) { return token; } - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { if (!window.sessionStorage.getItem(TOKEN_KEY)) { window.sessionStorage.setItem(TOKEN_KEY, generateUUID()); } @@ -81,7 +86,10 @@ export const getToken = () => { export const getBackendURL = (url_str) => { // Get backend URL object from the endpoint. const endpoint = new URL(url_str); - if ((typeof window !== 'undefined') && SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname)) { + if ( + typeof window !== "undefined" && + SAME_DOMAIN_HOSTNAMES.includes(endpoint.hostname) + ) { // Use the frontend domain to access the backend const frontend_hostname = window.location.hostname; endpoint.hostname = frontend_hostname; @@ -91,11 +99,11 @@ export const getBackendURL = (url_str) => { } else if (endpoint.protocol === "http:") { endpoint.protocol = "https:"; } - endpoint.port = ""; // Assume websocket is on https port via load balancer. + endpoint.port = ""; // Assume websocket is on https port via load balancer. } } - return endpoint -} + return endpoint; +}; /** * Apply a delta to the state. @@ -103,10 +111,9 @@ export const getBackendURL = (url_str) => { * @param delta The delta to apply. */ export const applyDelta = (state, delta) => { - return { ...state, ...delta } + return { ...state, ...delta }; }; - /** * Handle frontend event or send the event to the backend via Websocket. * @param event The event to send. @@ -117,10 +124,8 @@ export const applyDelta = (state, delta) => { export const applyEvent = async (event, socket) => { // Handle special events if (event.name == "_redirect") { - if (event.payload.external) - window.open(event.payload.path, "_blank"); - else - Router.push(event.payload.path); + if (event.payload.external) window.open(event.payload.path, "_blank"); + else Router.push(event.payload.path); return false; } @@ -130,20 +135,20 @@ export const applyEvent = async (event, socket) => { } if (event.name == "_remove_cookie") { - cookies.remove(event.payload.key, { ...event.payload.options }) - queueEvents(initialEvents(), socket) + cookies.remove(event.payload.key, { ...event.payload.options }); + queueEvents(initialEvents(), socket); return false; } if (event.name == "_clear_local_storage") { localStorage.clear(); - queueEvents(initialEvents(), socket) + queueEvents(initialEvents(), socket); return false; } if (event.name == "_remove_local_storage") { localStorage.removeItem(event.payload.key); - queueEvents(initialEvents(), socket) + queueEvents(initialEvents(), socket); return false; } @@ -154,9 +159,9 @@ export const applyEvent = async (event, socket) => { } if (event.name == "_download") { - const a = document.createElement('a'); + const a = document.createElement("a"); a.hidden = true; - a.href = event.payload.url + a.href = event.payload.url; a.download = event.payload.filename; a.click(); a.remove(); @@ -188,10 +193,10 @@ export const applyEvent = async (event, socket) => { try { const eval_result = eval(event.payload.javascript_code); if (event.payload.callback) { - if (!!eval_result && typeof eval_result.then === 'function') { - eval(event.payload.callback)(await eval_result) + if (!!eval_result && typeof eval_result.then === "function") { + eval(event.payload.callback)(await eval_result); } else { - eval(event.payload.callback)(eval_result) + eval(event.payload.callback)(eval_result); } } } catch (e) { @@ -201,14 +206,24 @@ export const applyEvent = async (event, socket) => { } // Update token and router data (if missing). - event.token = getToken() - if (event.router_data === undefined || Object.keys(event.router_data).length === 0) { - event.router_data = (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(Router) + event.token = getToken(); + if ( + event.router_data === undefined || + Object.keys(event.router_data).length === 0 + ) { + event.router_data = (({ pathname, query, asPath }) => ({ + pathname, + query, + asPath, + }))(Router); } // Send the event to the server. if (socket) { - socket.emit("event", JSON.stringify(event, (k, v) => v === undefined ? null : v)); + socket.emit( + "event", + JSON.stringify(event, (k, v) => (v === undefined ? null : v)) + ); return true; } @@ -244,17 +259,15 @@ export const applyRestEvent = async (event, socket) => { * @param socket The socket object to send the event on. */ export const queueEvents = async (events, socket) => { - event_queue.push(...events) - await processEvent(socket.current) -} + event_queue.push(...events); + await processEvent(socket.current); +}; /** * Process an event off the event queue. * @param socket The socket object to send the event on. */ -export const processEvent = async ( - socket -) => { +export const processEvent = async (socket) => { // Only proceed if the socket is up, otherwise we throw the event into the void if (!socket) { return; @@ -266,12 +279,12 @@ export const processEvent = async ( } // Set processing to true to block other events from being processed. - event_processing = true + event_processing = true; // Apply the next event in the queue. const event = event_queue.shift(); - let eventSent = false + let eventSent = false; // Process events with handlers via REST and all others via websockets. if (event.handler) { eventSent = await applyRestEvent(event, socket); @@ -283,27 +296,27 @@ export const processEvent = async ( event_processing = false; // recursively call processEvent to drain the queue, since there is // no state update to trigger the useEffect event loop. - await processEvent(socket) + await processEvent(socket); } -} +}; /** * Connect to a websocket and set the handlers. * @param socket The socket object to connect. * @param dispatch The function to queue state update * @param transports The transports to use. - * @param setConnectError The function to update connection error value. + * @param setConnectErrors The function to update connection error value. * @param client_storage The client storage object from context.js */ export const connect = async ( socket, dispatch, transports, - setConnectError, - client_storage = {}, + setConnectErrors, + client_storage = {} ) => { // Get backend URL object from the endpoint. - const endpoint = getBackendURL(EVENTURL) + const endpoint = getBackendURL(EVENTURL); // Create the socket. socket.current = io(endpoint.href, { @@ -314,23 +327,22 @@ export const connect = async ( // Once the socket is open, hydrate the page. socket.current.on("connect", () => { - setConnectError(null) + setConnectErrors([]); }); - socket.current.on('connect_error', (error) => { - setConnectError(error) + socket.current.on("connect_error", (error) => { + setConnectErrors((connectErrors) => [connectErrors.slice(-9), error]); }); - // On each received message, queue the updates and events. - socket.current.on("event", message => { - const update = JSON5.parse(message) + socket.current.on("event", (message) => { + const update = JSON5.parse(message); for (const substate in update.delta) { - dispatch[substate](update.delta[substate]) + dispatch[substate](update.delta[substate]); } - applyClientStorageDelta(client_storage, update.delta) - event_processing = !update.final + applyClientStorageDelta(client_storage, update.delta); + event_processing = !update.final; if (update.events) { - queueEvents(update.events, socket) + queueEvents(update.events, socket); } }); }; @@ -346,38 +358,44 @@ export const connect = async ( * * @returns The response from posting to the UPLOADURL endpoint. */ -export const uploadFiles = async (handler, files, upload_id, on_upload_progress, socket) => { +export const uploadFiles = async ( + handler, + files, + upload_id, + on_upload_progress, + socket +) => { // return if there's no file to upload if (files === undefined || files.length === 0) { return false; } if (upload_controllers[upload_id]) { - console.log("Upload already in progress for ", upload_id) + console.log("Upload already in progress for ", upload_id); return false; } let resp_idx = 0; const eventHandler = (progressEvent) => { // handle any delta / event streamed from the upload event handler - const chunks = progressEvent.event.target.responseText.trim().split("\n") + const chunks = progressEvent.event.target.responseText.trim().split("\n"); chunks.slice(resp_idx).map((chunk) => { try { socket._callbacks.$event.map((f) => { - f(chunk) - }) - resp_idx += 1 + f(chunk); + }); + resp_idx += 1; } catch (e) { if (progressEvent.progress === 1) { // Chunk may be incomplete, so only report errors when full response is available. - console.log("Error parsing chunk", chunk, e) + console.log("Error parsing chunk", chunk, e); } - return + return; } - }) - } + }); + }; - const controller = new AbortController() + const controller = new AbortController(); const config = { headers: { "Reflex-Client-Token": getToken(), @@ -385,26 +403,22 @@ export const uploadFiles = async (handler, files, upload_id, on_upload_progress, }, signal: controller.signal, onDownloadProgress: eventHandler, - } + }; if (on_upload_progress) { - config["onUploadProgress"] = on_upload_progress + config["onUploadProgress"] = on_upload_progress; } const formdata = new FormData(); // Add the token and handler to the file name. files.forEach((file) => { - formdata.append( - "files", - file, - file.path || file.name - ); - }) + formdata.append("files", file, file.path || file.name); + }); // Send the file to the server. - upload_controllers[upload_id] = controller + upload_controllers[upload_id] = controller; try { - return await axios.post(getBackendURL(UPLOADURL), formdata, config) + return await axios.post(getBackendURL(UPLOADURL), formdata, config); } catch (error) { if (error.response) { // The request was made and the server responded with a status code @@ -421,7 +435,7 @@ export const uploadFiles = async (handler, files, upload_id, on_upload_progress, } return false; } finally { - delete upload_controllers[upload_id] + delete upload_controllers[upload_id]; } }; @@ -443,30 +457,32 @@ export const Event = (name, payload = {}, handler = null) => { * @returns payload dict of client storage values */ export const hydrateClientStorage = (client_storage) => { - const client_storage_values = {} + const client_storage_values = {}; if (client_storage.cookies) { for (const state_key in client_storage.cookies) { - const cookie_options = client_storage.cookies[state_key] - const cookie_name = cookie_options.name || state_key - const cookie_value = cookies.get(cookie_name) + const cookie_options = client_storage.cookies[state_key]; + const cookie_name = cookie_options.name || state_key; + const cookie_value = cookies.get(cookie_name); if (cookie_value !== undefined) { - client_storage_values[state_key] = cookies.get(cookie_name) + client_storage_values[state_key] = cookies.get(cookie_name); } } } - if (client_storage.local_storage && (typeof window !== 'undefined')) { + if (client_storage.local_storage && typeof window !== "undefined") { for (const state_key in client_storage.local_storage) { - const options = client_storage.local_storage[state_key] - const local_storage_value = localStorage.getItem(options.name || state_key) + const options = client_storage.local_storage[state_key]; + const local_storage_value = localStorage.getItem( + options.name || state_key + ); if (local_storage_value !== null) { - client_storage_values[state_key] = local_storage_value + client_storage_values[state_key] = local_storage_value; } } } if (client_storage.cookies || client_storage.local_storage) { - return client_storage_values + return client_storage_values; } - return {} + return {}; }; /** @@ -476,9 +492,11 @@ export const hydrateClientStorage = (client_storage) => { */ const applyClientStorageDelta = (client_storage, delta) => { // find the main state and check for is_hydrated - const unqualified_states = Object.keys(delta).filter((key) => key.split(".").length === 1); + const unqualified_states = Object.keys(delta).filter( + (key) => key.split(".").length === 1 + ); if (unqualified_states.length === 1) { - const main_state = delta[unqualified_states[0]] + const main_state = delta[unqualified_states[0]]; if (main_state.is_hydrated !== undefined && !main_state.is_hydrated) { // skip if the state is not hydrated yet, since all client storage // values are sent in the hydrate event @@ -488,19 +506,23 @@ const applyClientStorageDelta = (client_storage, delta) => { // Save known client storage values to cookies and localStorage. for (const substate in delta) { for (const key in delta[substate]) { - const state_key = `${substate}.${key}` + const state_key = `${substate}.${key}`; if (client_storage.cookies && state_key in client_storage.cookies) { - const cookie_options = { ...client_storage.cookies[state_key] } - const cookie_name = cookie_options.name || state_key - delete cookie_options.name // name is not a valid cookie option + const cookie_options = { ...client_storage.cookies[state_key] }; + const cookie_name = cookie_options.name || state_key; + delete cookie_options.name; // name is not a valid cookie option cookies.set(cookie_name, delta[substate][key], cookie_options); - } else if (client_storage.local_storage && state_key in client_storage.local_storage && (typeof window !== 'undefined')) { - const options = client_storage.local_storage[state_key] + } else if ( + client_storage.local_storage && + state_key in client_storage.local_storage && + typeof window !== "undefined" + ) { + const options = client_storage.local_storage[state_key]; localStorage.setItem(options.name || state_key, delta[substate][key]); } } } -} +}; /** * Establish websocket event loop for a NextJS page. @@ -508,18 +530,18 @@ const applyClientStorageDelta = (client_storage, delta) => { * @param initial_events The initial app events. * @param client_storage The client storage object from context.js * - * @returns [addEvents, connectError] - + * @returns [addEvents, connectErrors] - * addEvents is used to queue an event, and - * connectError is a reactive js error from the websocket connection (or null if connected). + * connectErrors is an array of reactive js error from the websocket connection (or null if connected). */ export const useEventLoop = ( dispatch, initial_events = () => [], - client_storage = {}, + client_storage = {} ) => { - const socket = useRef(null) - const router = useRouter() - const [connectError, setConnectError] = useState(null) + const socket = useRef(null); + const router = useRouter(); + const [connectErrors, setConnectErrors] = useState([]); // Function to add new events to the event queue. const addEvents = (events, _e, event_actions) => { @@ -529,22 +551,26 @@ export const useEventLoop = ( if (event_actions?.stopPropagation && _e?.stopPropagation) { _e.stopPropagation(); } - queueEvents(events, socket) - } + queueEvents(events, socket); + }; - const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode + const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { if (router.isReady && !sentHydrate.current) { - const events = initial_events() - addEvents(events.map((e) => ( - { + const events = initial_events(); + addEvents( + events.map((e) => ({ ...e, - router_data: (({ pathname, query, asPath }) => ({ pathname, query, asPath }))(router) - } - ))) - sentHydrate.current = true + router_data: (({ pathname, query, asPath }) => ({ + pathname, + query, + asPath, + }))(router), + })) + ); + sentHydrate.current = true; } - }, [router.isReady]) + }, [router.isReady]); // Main event loop. useEffect(() => { @@ -556,17 +582,22 @@ export const useEventLoop = ( if (Object.keys(initialState).length > 1) { // Initialize the websocket connection. if (!socket.current) { - connect(socket, dispatch, ['websocket', 'polling'], setConnectError, client_storage) + connect( + socket, + dispatch, + ["websocket", "polling"], + setConnectErrors, + client_storage + ); } (async () => { // Process all outstanding events. while (event_queue.length > 0 && !event_processing) { - await processEvent(socket.current) + await processEvent(socket.current); } - })() + })(); } - }) - + }); // localStorage event handling useEffect(() => { @@ -585,9 +616,12 @@ export const useEventLoop = ( // e is StorageEvent const handleStorage = (e) => { if (storage_to_state_map[e.key]) { - const vars = {} - vars[storage_to_state_map[e.key]] = e.newValue - const event = Event(`${state_name}.update_vars_internal_state.update_vars_internal`, {vars: vars}) + const vars = {}; + vars[storage_to_state_map[e.key]] = e.newValue; + const event = Event( + `${state_name}.update_vars_internal_state.update_vars_internal`, + { vars: vars } + ); addEvents([event], e); } }; @@ -596,18 +630,17 @@ export const useEventLoop = ( return () => window.removeEventListener("storage", handleStorage); }); - // Route after the initial page hydration. useEffect(() => { - const change_complete = () => addEvents(onLoadInternalEvent()) - router.events.on('routeChangeComplete', change_complete) + const change_complete = () => addEvents(onLoadInternalEvent()); + router.events.on("routeChangeComplete", change_complete); return () => { - router.events.off('routeChangeComplete', change_complete) - } - }, [router]) + router.events.off("routeChangeComplete", change_complete); + }; + }, [router]); - return [addEvents, connectError] -} + return [addEvents, connectErrors]; +}; /*** * Check if a value is truthy in python. @@ -628,21 +661,25 @@ export const getRefValue = (ref) => { return; } if (ref.current.type == "checkbox") { - return ref.current.checked; // chakra - } else if (ref.current.className?.includes("rt-CheckboxButton") || ref.current.className?.includes("rt-SwitchButton")) { - return ref.current.ariaChecked == "true"; // radix + return ref.current.checked; // chakra + } else if ( + ref.current.className?.includes("rt-CheckboxButton") || + ref.current.className?.includes("rt-SwitchButton") + ) { + return ref.current.ariaChecked == "true"; // radix } else if (ref.current.className?.includes("rt-SliderRoot")) { // find the actual slider return ref.current.querySelector(".rt-SliderThumb")?.ariaValueNow; } else { //querySelector(":checked") is needed to get value from radio_group - return ref.current.value || ( - ref.current.querySelector - && ref.current.querySelector(':checked') - && ref.current.querySelector(':checked')?.value + return ( + ref.current.value || + (ref.current.querySelector && + ref.current.querySelector(":checked") && + ref.current.querySelector(":checked")?.value) ); } -} +}; /** * Get the values from a ref array. @@ -654,21 +691,25 @@ export const getRefValues = (refs) => { return; } // getAttribute is used by RangeSlider because it doesn't assign value - return refs.map((ref) => ref.current ? ref.current.value || ref.current.getAttribute("aria-valuenow") : null); -} + return refs.map((ref) => + ref.current + ? ref.current.value || ref.current.getAttribute("aria-valuenow") + : null + ); +}; /** -* Spread two arrays or two objects. -* @param first The first array or object. -* @param second The second array or object. -* @returns The final merged array or object. -*/ + * Spread two arrays or two objects. + * @param first The first array or object. + * @param second The second array or object. + * @returns The final merged array or object. + */ export const spreadArraysOrObjects = (first, second) => { if (Array.isArray(first) && Array.isArray(second)) { return [...first, ...second]; - } else if (typeof first === 'object' && typeof second === 'object') { + } else if (typeof first === "object" && typeof second === "object") { return { ...first, ...second }; } else { - throw new Error('Both parameters must be either arrays or objects.'); + throw new Error("Both parameters must be either arrays or objects."); } -} +}; diff --git a/reflex/app.py b/reflex/app.py index fca37d9ef..67bf17f51 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1,4 +1,5 @@ """The main Reflex app.""" + from __future__ import annotations import asyncio @@ -36,7 +37,7 @@ from reflex.admin import AdminDash from reflex.base import Base from reflex.compiler import compiler from reflex.compiler import utils as compiler_utils -from reflex.components import connection_modal +from reflex.components import connection_modal, connection_pulser from reflex.components.base.app_wrap import AppWrap from reflex.components.base.fragment import Fragment from reflex.components.component import ( @@ -87,7 +88,7 @@ def default_overlay_component() -> Component: Returns: The default overlay_component, which is a connection_modal. """ - return connection_modal() + return Fragment.create(connection_pulser(), connection_modal()) class App(Base): @@ -198,9 +199,11 @@ class App(Base): # Set up the Socket.IO AsyncServer. self.sio = AsyncServer( async_mode="asgi", - cors_allowed_origins="*" - if config.cors_allowed_origins == ["*"] - else config.cors_allowed_origins, + cors_allowed_origins=( + "*" + if config.cors_allowed_origins == ["*"] + else config.cors_allowed_origins + ), cors_credentials=True, max_http_buffer_size=constants.POLLING_MAX_HTTP_BUFFER_SIZE, ping_interval=constants.Ping.INTERVAL, @@ -387,10 +390,9 @@ class App(Base): title: str = constants.DefaultPage.TITLE, description: str = constants.DefaultPage.DESCRIPTION, image: str = constants.DefaultPage.IMAGE, - on_load: EventHandler - | EventSpec - | list[EventHandler | EventSpec] - | None = None, + on_load: ( + EventHandler | EventSpec | list[EventHandler | EventSpec] | None + ) = None, meta: list[dict[str, str]] = constants.DefaultPage.META_LIST, script_tags: list[Component] | None = None, ): @@ -520,10 +522,9 @@ class App(Base): title: str = constants.Page404.TITLE, image: str = constants.Page404.IMAGE, description: str = constants.Page404.DESCRIPTION, - on_load: EventHandler - | EventSpec - | list[EventHandler | EventSpec] - | None = None, + on_load: ( + EventHandler | EventSpec | list[EventHandler | EventSpec] | None + ) = None, meta: list[dict[str, str]] = constants.DefaultPage.META_LIST, ): """Define a custom 404 page for any url having no match. diff --git a/reflex/components/core/__init__.py b/reflex/components/core/__init__.py index 11c554bfd..3bc27a157 100644 --- a/reflex/components/core/__init__.py +++ b/reflex/components/core/__init__.py @@ -1,7 +1,7 @@ """Core Reflex components.""" from . import layout as layout -from .banner import ConnectionBanner, ConnectionModal +from .banner import ConnectionBanner, ConnectionModal, ConnectionPulser from .colors import color from .cond import Cond, color_mode_cond, cond from .debounce import DebounceInput @@ -26,6 +26,7 @@ from .upload import ( connection_banner = ConnectionBanner.create connection_modal = ConnectionModal.create +connection_pulser = ConnectionPulser.create debounce_input = DebounceInput.create foreach = Foreach.create html = Html.create diff --git a/reflex/components/core/banner.py b/reflex/components/core/banner.py index 182d6a5e5..2e634f55a 100644 --- a/reflex/components/core/banner.py +++ b/reflex/components/core/banner.py @@ -7,14 +7,17 @@ from typing import Optional from reflex.components.base.bare import Bare from reflex.components.component import Component from reflex.components.core.cond import cond +from reflex.components.el.elements.typography import Div +from reflex.components.lucide.icon import Icon from reflex.components.radix.themes.components.dialog import ( DialogContent, DialogRoot, DialogTitle, ) -from reflex.components.radix.themes.layout import Box +from reflex.components.radix.themes.layout import Flex from reflex.components.radix.themes.typography.text import Text from reflex.constants import Dirs, Hooks, Imports +from reflex.state import State from reflex.utils import imports from reflex.vars import Var, VarData @@ -24,12 +27,24 @@ connect_error_var_data: VarData = VarData( # type: ignore ) connection_error: Var = Var.create_safe( - value="(connectError !== null) ? connectError.message : ''", + value="(connectErrors.length > 0) ? connectErrors[connectErrors.length - 1].message : ''", _var_is_local=False, _var_is_string=False, )._replace(merge_var_data=connect_error_var_data) -has_connection_error: Var = Var.create_safe( - value="connectError !== null", + +connection_errors_count: Var = Var.create_safe( + value="connectErrors.length", + _var_is_string=False, + _var_is_local=False, +)._replace(merge_var_data=connect_error_var_data) + +has_connection_errors: Var = Var.create_safe( + value="connectErrors.length > 0", + _var_is_string=False, +)._replace(_var_type=bool, merge_var_data=connect_error_var_data) + +has_too_many_connection_errors: Var = Var.create_safe( + value="connectErrors.length >= 2", _var_is_string=False, )._replace(_var_type=bool, merge_var_data=connect_error_var_data) @@ -81,16 +96,20 @@ class ConnectionBanner(Component): The connection banner component. """ if not comp: - comp = Box.create( + comp = Flex.create( Text.create( *default_connection_error(), - bg="red", - color="white", + color="black", + size="4", ), - textAlign="center", + justify="center", + background_color="crimson", + width="100vw", + padding="5px", + position="fixed", ) - return cond(has_connection_error, comp) + return cond(has_connection_errors, comp) class ConnectionModal(Component): @@ -109,12 +128,81 @@ class ConnectionModal(Component): if not comp: comp = Text.create(*default_connection_error()) return cond( - has_connection_error, + has_too_many_connection_errors, DialogRoot.create( DialogContent.create( DialogTitle.create("Connection Error"), comp, ), - open=has_connection_error, + open=has_too_many_connection_errors, + z_index=9999, ), ) + + +class WifiOffPulse(Icon): + """A wifi_off icon with an animated opacity pulse.""" + + @classmethod + def create(cls, **props) -> Component: + """Create a wifi_off icon with an animated opacity pulse. + + Args: + **props: The properties of the component. + + Returns: + The icon component with default props applied. + """ + return super().create( + "wifi_off", + color=props.pop("color", "crimson"), + size=props.pop("size", 32), + z_index=props.pop("z_index", 9999), + position=props.pop("position", "fixed"), + bottom=props.pop("botton", "30px"), + right=props.pop("right", "30px"), + animation=Var.create(f"${{pulse}} 1s infinite", _var_is_string=True), + **props, + ) + + def _get_imports(self) -> imports.ImportDict: + return imports.merge_imports( + super()._get_imports(), + {"@emotion/react": [imports.ImportVar(tag="keyframes")]}, + ) + + def _get_custom_code(self) -> str | None: + return """ +const pulse = keyframes` + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +` +""" + + +class ConnectionPulser(Div): + """A connection pulser component.""" + + @classmethod + def create(cls, **props) -> Component: + """Create a connection pulser component. + + Args: + **props: The properties of the component. + + Returns: + The connection pulser component. + """ + return super().create( + cond( + ~State.is_hydrated | has_connection_errors, # type: ignore + WifiOffPulse.create(**props), + ), + position="fixed", + width="100vw", + height="0", + ) diff --git a/reflex/components/core/banner.pyi b/reflex/components/core/banner.pyi index f7c7b2577..89405518f 100644 --- a/reflex/components/core/banner.pyi +++ b/reflex/components/core/banner.pyi @@ -11,20 +11,25 @@ from typing import Optional from reflex.components.base.bare import Bare from reflex.components.component import Component from reflex.components.core.cond import cond +from reflex.components.el.elements.typography import Div +from reflex.components.lucide.icon import Icon from reflex.components.radix.themes.components.dialog import ( DialogContent, DialogRoot, DialogTitle, ) -from reflex.components.radix.themes.layout import Box +from reflex.components.radix.themes.layout import Flex from reflex.components.radix.themes.typography.text import Text from reflex.constants import Dirs, Hooks, Imports +from reflex.state import State from reflex.utils import imports from reflex.vars import Var, VarData connect_error_var_data: VarData connection_error: Var -has_connection_error: Var +connection_errors_count: Var +has_connection_errors: Var +has_too_many_connection_errors: Var class WebsocketTargetURL(Bare): @overload @@ -232,3 +237,211 @@ class ConnectionModal(Component): The connection banner component. """ ... + +class WifiOffPulse(Icon): + @overload + @classmethod + def create( # type: ignore + cls, + *children, + size: Optional[Union[Var[int], int]] = None, + style: Optional[Style] = None, + key: Optional[Any] = None, + id: Optional[Any] = None, + class_name: Optional[Any] = None, + autofocus: Optional[bool] = None, + custom_attrs: Optional[Dict[str, Union[Var, str]]] = None, + on_blur: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_context_menu: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_double_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_focus: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_down: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_enter: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_leave: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_move: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_out: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_over: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_up: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_scroll: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_unmount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + **props + ) -> "WifiOffPulse": + """Create a wifi_off icon with an animated opacity pulse. + + Args: + size: The size of the icon in pixels. + style: The style of the component. + key: A unique key for the component. + id: The id for the component. + class_name: The class name for the component. + autofocus: Whether the component should take the focus once the page is loaded + custom_attrs: custom attribute + **props: The properties of the component. + + Returns: + The icon component with default props applied. + """ + ... + +class ConnectionPulser(Div): + @overload + @classmethod + def create( # type: ignore + cls, + *children, + access_key: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + auto_capitalize: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + content_editable: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + context_menu: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + dir: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None, + draggable: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + enter_key_hint: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + hidden: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + input_mode: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + item_prop: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + lang: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None, + role: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None, + slot: Optional[Union[Var[Union[str, int, bool]], Union[str, int, bool]]] = None, + spell_check: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + tab_index: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + title: Optional[ + Union[Var[Union[str, int, bool]], Union[str, int, bool]] + ] = None, + style: Optional[Style] = None, + key: Optional[Any] = None, + id: Optional[Any] = None, + class_name: Optional[Any] = None, + autofocus: Optional[bool] = None, + custom_attrs: Optional[Dict[str, Union[Var, str]]] = None, + on_blur: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_context_menu: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_double_click: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_focus: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_down: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_enter: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_leave: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_move: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_out: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_over: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_mouse_up: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_scroll: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_unmount: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + **props + ) -> "ConnectionPulser": + """Create a connection pulser component. + + Args: + access_key: Provides a hint for generating a keyboard shortcut for the current element. + auto_capitalize: Controls whether and how text input is automatically capitalized as it is entered/edited by the user. + content_editable: Indicates whether the element's content is editable. + context_menu: Defines the ID of a element which will serve as the element's context menu. + dir: Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left) + draggable: Defines whether the element can be dragged. + enter_key_hint: Hints what media types the media element is able to play. + hidden: Defines whether the element is hidden. + input_mode: Defines the type of the element. + item_prop: Defines the name of the element for metadata purposes. + lang: Defines the language used in the element. + role: Defines the role of the element. + slot: Assigns a slot in a shadow DOM shadow tree to an element. + spell_check: Defines whether the element may be checked for spelling errors. + tab_index: Defines the position of the current element in the tabbing order. + title: Defines a tooltip for the element. + style: The style of the component. + key: A unique key for the component. + id: The id for the component. + class_name: The class name for the component. + autofocus: Whether the component should take the focus once the page is loaded + custom_attrs: custom attribute + **props: The properties of the component. + + Returns: + The connection pulser component. + """ + ... diff --git a/reflex/constants/compiler.py b/reflex/constants/compiler.py index 2ec23f5e9..f172dfcec 100644 --- a/reflex/constants/compiler.py +++ b/reflex/constants/compiler.py @@ -1,4 +1,5 @@ """Compiler variables.""" + import enum from enum import Enum from types import SimpleNamespace @@ -55,7 +56,7 @@ class CompileVars(SimpleNamespace): # The name of the function to add events to the queue. ADD_EVENTS = "addEvents" # The name of the var storing any connection error. - CONNECT_ERROR = "connectError" + CONNECT_ERROR = "connectErrors" # The name of the function for converting a dict to an event. TO_EVENT = "Event" # The name of the internal on_load event.