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 <m_github@0x26.net>
This commit is contained in:
Thomas Brandého 2024-02-29 19:01:12 +01:00 committed by GitHub
parent bea9eb4349
commit cc678e8648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 521 additions and 176 deletions

View File

@ -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 (
<EventLoopContext.Provider value={[addEvents, connectError]}>
<EventLoopContext.Provider value={[addEvents, connectErrors]}>
{children}
</EventLoopContext.Provider>
)

View File

@ -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.");
}
}
};

View File

@ -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.

View File

@ -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

View File

@ -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",
)

View File

@ -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 <menu> 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.
"""
...

View File

@ -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.