From 4dae5384cb79be902a939c009b535c759f0ddef7 Mon Sep 17 00:00:00 2001 From: Dev Aggarwal Date: Thu, 12 Sep 2024 20:12:22 +0530 Subject: [PATCH] Modularization of Modal Component: - The Modal class has been replaced with more specialized components using Pydantic models for better state management (AlertDialogRef, ConfirmDialogRef) - Functions like use_alert_dialog, use_confirm_dialog, alert_dialog, and confirm_dialog manage modal states and rendering. Component Enhancements: - Added button_with_confirm_dialog to handle button interactions related to confirm dialogs. - The modal_scaffold function creates a standardized structure for modals, supporting large modals with appropriate bootstrap CSS classes. - Add Modal Animations Renderer Context Management: - Introduced current_root_ctx for handling the context of the current rendering tree. Utilizes thread-local storage for managing the root context, ensuring consistent state across renders. Error Handling: - Handle undefined loaderHeaders - Ignore network errors in sentry - Reduce UI layout change on lazy load Version Bump: - Updated the version from 0.1.1 to 0.2.0, indicating significant changes. --- app/base.tsx | 15 +- app/components/GooeySelect.tsx | 2 +- app/entry.client.tsx | 33 ++-- app/entry.server.tsx | 7 +- app/global-progres-bar.tsx | 1 + app/lazyImports.tsx | 6 +- app/renderedHTML.tsx | 5 +- app/root.tsx | 3 +- app/styles/custom.css | 28 +++- py/gooey_gui/components/__init__.py | 11 +- py/gooey_gui/components/modal.py | 245 ++++++++++++++++++---------- py/gooey_gui/core/__init__.py | 2 +- py/gooey_gui/core/renderer.py | 17 +- py/pyproject.toml | 8 +- 14 files changed, 254 insertions(+), 129 deletions(-) diff --git a/app/base.tsx b/app/base.tsx index db142cf..0e6de4b 100644 --- a/app/base.tsx +++ b/app/base.tsx @@ -22,12 +22,15 @@ import { lazyImport } from "./lazyImports"; const { DataTable, DataTableRaw } = lazyImport(() => import("~/dataTable")); const { GooeyFileInput } = lazyImport(() => import("~/gooeyFileInput"), { - fallback: ( -
- Loading... + fallback: ({ label }) => ( +
+ {label && } +
+ Loading... +
), }); diff --git a/app/components/GooeySelect.tsx b/app/components/GooeySelect.tsx index 7e902f4..7bd6551 100644 --- a/app/components/GooeySelect.tsx +++ b/app/components/GooeySelect.tsx @@ -61,7 +61,7 @@ export default function GooeySelect({ Loading... diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 0a7e260..ef3aa18 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,25 +1,21 @@ -import { useLocation, useMatches, RemixBrowser } from "@remix-run/react"; -import { hydrate } from "react-dom"; +import { RemixBrowser, useLocation, useMatches } from "@remix-run/react"; import * as Sentry from "@sentry/remix"; import { useEffect } from "react"; -import { HttpClient, Offline } from "@sentry/integrations"; +import { hydrate } from "react-dom"; Sentry.init({ dsn: window.ENV.SENTRY_DSN, + release: window.ENV.SENTRY_RELEASE, + environment: "client", integrations: [ - new Sentry.BrowserTracing({ - // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled - // tracePropagationTargets: ["localhost", /^https:\/\/gooey\.ai\/.*/], - routingInstrumentation: Sentry.remixRouterInstrumentation( - useEffect, - useLocation, - useMatches - ), + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, }), - new Sentry.Replay(), - new HttpClient(), + Sentry.replayIntegration(), + Sentry.httpClientIntegration(), ], - release: window.ENV.SENTRY_RELEASE, // Performance Monitoring tracesSampleRate: 0.005, // Capture X% of the transactions, reduce in production! // Session Replay @@ -27,7 +23,16 @@ Sentry.init({ replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. // This option is required for capturing headers and cookies. sendDefaultPii: true, + // To enable offline events caching, use makeBrowserOfflineTransport to wrap existing transports and queue events using the browsers' IndexedDB storage. + // Once your application comes back online, all events will be sent together. transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport), + // You can use the ignoreErrors option to filter out errors that match a certain pattern. + ignoreErrors: [ + /TypeError: Failed to fetch/i, + /TypeError: Load failed/i, + /(network)(\s+)(error)/i, + /AbortError/i, + ], }); hydrate(, document); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 4cf0763..cfedd4e 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -8,6 +8,7 @@ import settings from "./settings"; if (settings.SENTRY_DSN) { Sentry.init({ dsn: settings.SENTRY_DSN, + environment: "server", // Integrations: // e.g. new Sentry.Integrations.Prisma({ client: prisma }) // Performance Monitoring: @@ -42,8 +43,10 @@ export default function handleDocumentRequestFunction( ); - for (let [key, value] of loaderHeaders.entries()) { - responseHeaders.set(key, value); + if (loaderHeaders) { + for (let [key, value] of loaderHeaders.entries()) { + responseHeaders.set(key, value); + } } responseHeaders.delete("Content-Length"); responseHeaders.set("Content-Type", "text/html"); diff --git a/app/global-progres-bar.tsx b/app/global-progres-bar.tsx index 62917d9..ee5e661 100644 --- a/app/global-progres-bar.tsx +++ b/app/global-progres-bar.tsx @@ -15,6 +15,7 @@ export const useGlobalProgress = () => { const fetchers = useFetchers(); useEffect(() => { + if (!document.querySelector(parent)) return; NProgress.configure({ parent, trickleSpeed: 100 }); }, []); diff --git a/app/lazyImports.tsx b/app/lazyImports.tsx index c225c5f..b41a0cb 100644 --- a/app/lazyImports.tsx +++ b/app/lazyImports.tsx @@ -4,7 +4,9 @@ import LoadingFallback from "./loadingfallback"; export function lazyImport( loader: () => Promise, - { fallback }: { fallback?: React.ReactNode } = {} + { + fallback, + }: { fallback?: (props: Record) => React.ReactNode } = {} ): T { return new Proxy( {}, @@ -22,7 +24,7 @@ export function lazyImport( return (props: any) => { return ( - + {() => } ); diff --git a/app/renderedHTML.tsx b/app/renderedHTML.tsx index 4843d78..545f54c 100644 --- a/app/renderedHTML.tsx +++ b/app/renderedHTML.tsx @@ -22,7 +22,10 @@ export function RenderedHTML({ return ; } - const parsedElements = parse(body, reactParserOptions); + if (typeof body !== "string") { + body = JSON.stringify(body); + } + const parsedElements = parse(body || "", reactParserOptions); return ( diff --git a/app/root.tsx b/app/root.tsx index 90950aa..e2577a1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -77,7 +77,8 @@ export default function App() { const reloadOnErrors = [ "TypeError: Failed to fetch", "TypeError: Load failed", - "A network error", + "Network Error", + "NetworkError", ]; const ignoreErrors = ["AbortError"]; diff --git a/app/styles/custom.css b/app/styles/custom.css index 74c8655..9233f5d 100644 --- a/app/styles/custom.css +++ b/app/styles/custom.css @@ -753,4 +753,30 @@ a.text-primary:hover { } .cm-lineNumbers .cm-gutterElement { min-width: 36px !important; -} \ No newline at end of file +} + +/* Modal animations */ +@keyframes popOut { + 0% { + transform: scale(0.5); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} +.modal-dialog { + animation: popOut 0.2s forwards; +} +@keyframes fadeInBackground { + 0% { + background-color: rgba(0, 0, 0, 0); + } + 100% { + background-color: rgba(0, 0, 0, 0.5); + } +} +.modal { + animation: fadeInBackground 1s forwards; +} diff --git a/py/gooey_gui/components/__init__.py b/py/gooey_gui/components/__init__.py index fa430f6..80aa080 100644 --- a/py/gooey_gui/components/__init__.py +++ b/py/gooey_gui/components/__init__.py @@ -1,4 +1,13 @@ from .common import * -from .modal import Modal +from .modal import ( + AlertDialogRef, + ConfirmDialogRef, + use_alert_dialog, + modal_scaffold, + alert_dialog, + use_confirm_dialog, + confirm_dialog, + button_with_confirm_dialog, +) from .pills import pill from .url_button import url_button diff --git a/py/gooey_gui/components/modal.py b/py/gooey_gui/components/modal.py index 89dbc64..02a72c7 100644 --- a/py/gooey_gui/components/modal.py +++ b/py/gooey_gui/components/modal.py @@ -1,96 +1,163 @@ -from contextlib import contextmanager +from pydantic import BaseModel from gooey_gui import core from gooey_gui.components import common as gui -class Modal: - def __init__(self, title, key, padding=20, max_width=744): - """ - :param title: title of the Modal shown in the h1 - :param key: unique key identifying this modal instance - :param padding: padding of the content within the modal - :param max_width: maximum width this modal should use - """ - self.title = title - self.padding = padding - self.max_width = str(max_width) + "px" - self.key = key - - self._container = None - - def is_open(self): - return core.session_state.get(f"{self.key}-opened", False) - - def open(self): - core.session_state[f"{self.key}-opened"] = True - core.rerun() - - def close(self, rerun_condition=True): - core.session_state[f"{self.key}-opened"] = False - if rerun_condition: - core.rerun() - - def empty(self): - if self._container: - self._container.empty() - - @contextmanager - def container(self, **props): - gui.html( - f""" - - """ +class AlertDialogRef(BaseModel): + key: str + is_open: bool = False + + def set_open(self, value: bool): + self.is_open = core.session_state[self.key] = value + + @property + def open_btn_key(self): + return self.key + ":open" + + @property + def close_btn_key(self): + return self.key + ":close" + + +class ConfirmDialogRef(AlertDialogRef): + pressed_confirm: bool = False + + @property + def confirm_btn_key(self): + return self.key + ":confirm" + + +def use_confirm_dialog( + key: str, + close_on_confirm: bool = True, +) -> ConfirmDialogRef: + ref = ConfirmDialogRef.parse_obj(use_alert_dialog(key)) + if not ref.is_open: + return ref + + ref.pressed_confirm = bool(core.session_state.pop(ref.confirm_btn_key, None)) + if ref.pressed_confirm and close_on_confirm: + ref.set_open(False) + + return ref + + +def use_alert_dialog(key: str) -> AlertDialogRef: + ref = AlertDialogRef(key=key, is_open=bool(core.session_state.get(key))) + if core.session_state.pop(ref.close_btn_key, None): + ref.set_open(False) + return ref + + +def alert_dialog( + ref: AlertDialogRef, + modal_title: str, + large: bool = False, +) -> core.NestingCtx: + header, body, _ = modal_scaffold(large=large) + with header: + gui.write(modal_title) + gui.button( + '', + key=ref.close_btn_key, + type="tertiary", + className="py-1 px-2 mb-1", + ) + return body + + +def button_with_confirm_dialog( + *, + ref: ConfirmDialogRef, + trigger_label: str, + modal_title: str, + modal_content: str | None = None, + cancel_label: str = "Cancel", + confirm_label: str, + trigger_className: str = "", + trigger_type: str = "secondary", + cancel_className: str = "", + confirm_className: str = "", + large: bool = False, +) -> core.NestingCtx: + if gui.button( + label=trigger_label, + key=ref.open_btn_key, + className=trigger_className, + type=trigger_type, + ): + ref.set_open(True) + if ref.is_open: + return confirm_dialog( + ref=ref, + modal_title=modal_title, + modal_content=modal_content, + cancel_label=cancel_label, + confirm_label=confirm_label, + cancel_className=cancel_className, + confirm_className=confirm_className, + large=large, ) + return gui.dummy() + + +def confirm_dialog( + *, + ref: ConfirmDialogRef, + modal_title: str, + modal_content: str | None = None, + cancel_label: str = "Cancel", + confirm_label: str, + cancel_className: str = "", + confirm_className: str = "", + large: bool = False, +) -> core.NestingCtx: + header, body, footer = modal_scaffold(large=large) + with header: + gui.write(modal_title) + with footer: + gui.button( + label=cancel_label, + key=ref.close_btn_key, + className=cancel_className, + type="tertiary", + ) + gui.button( + label=confirm_label, + key=ref.confirm_btn_key, + type="primary", + className=confirm_className, + ) + if modal_content: + with body: + gui.write(modal_content) + return body + - with gui.div(className="blur-background"): - with gui.div(className="modal-parent"): - container_class = "modal-container " + props.pop("className", "") - self._container = gui.div(className=container_class, **props) - - with self._container: - with gui.div(className="d-flex justify-content-between align-items-center"): - if self.title: - gui.markdown(f"### {self.title}") - else: - gui.div() - - close_ = gui.button( - "✖", - type="tertiary", - key=f"{self.key}-close", - style={"padding": "0.375rem 0.75rem"}, - ) - if close_: - self.close() - yield self._container +def modal_scaffold( + large: bool = False, +) -> tuple[core.NestingCtx, core.NestingCtx, core.NestingCtx]: + if large: + large_cls = "modal-lg" + else: + large_cls = "" + with core.current_root_ctx(): + with ( + gui.div( + className="modal d-block", + style=dict(zIndex="9999"), + tabIndex="-1", + role="dialog", + ), + gui.div( + className=f"modal-dialog modal-dialog-centered {large_cls}", + role="document", + ), + gui.div(className="modal-content border-0 shadow"), + ): + return ( + gui.div(className="modal-header border-0"), + gui.div(className="modal-body"), + gui.div(className="modal-footer border-0 pb-0"), + ) diff --git a/py/gooey_gui/core/__init__.py b/py/gooey_gui/core/__init__.py index 94674d8..602aa15 100644 --- a/py/gooey_gui/core/__init__.py +++ b/py/gooey_gui/core/__init__.py @@ -16,7 +16,7 @@ realtime_clear_subs, md5_values, ) -from .renderer import RenderTreeNode, NestingCtx, renderer, route +from .renderer import RenderTreeNode, NestingCtx, renderer, route, current_root_ctx from .state import ( get_session_state, set_session_state, diff --git a/py/gooey_gui/core/renderer.py b/py/gooey_gui/core/renderer.py index 408efb2..4157f20 100644 --- a/py/gooey_gui/core/renderer.py +++ b/py/gooey_gui/core/renderer.py @@ -19,6 +19,10 @@ ReactHTMLProps = dict[str, typing.Any] +def current_root_ctx() -> "NestingCtx": + return threadlocal.root_ctx + + class RenderTreeNode(BaseModel): name: str props: ReactHTMLProps = {} @@ -107,13 +111,14 @@ def renderer( while True: try: root = RenderTreeNode(name="root") - try: - with NestingCtx(root): + threadlocal.root_ctx = NestingCtx(root) + with threadlocal.root_ctx: + try: ret = render() - except StopException: - ret = None - except RedirectException as e: - return RedirectResponse(e.url, status_code=e.status_code) + except StopException: + ret = None + except RedirectException as e: + return RedirectResponse(e.url, status_code=e.status_code) if isinstance(ret, Response): return ret return JSONResponse( diff --git a/py/pyproject.toml b/py/pyproject.toml index 20fb2e1..10b5b11 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -1,16 +1,16 @@ [tool.poetry] name = "gooey-gui" -version = "0.1.1" +version = "0.2.0" description = "" authors = ["Dev Aggarwal "] license = "Apache-2.0" [tool.poetry.dependencies] python = "^3.10" -pydantic = "^1.10.12" -fastapi = "^0.85.2" +pydantic = "*" +fastapi = "*" redis = "^4.5.1" -uvicorn = { extras = ["standard"], version = "^0.18.3" } +uvicorn = { extras = ["standard"], version = "*" } furl = "^2.1.3" python-multipart = "^0.0.6" python-decouple = "^3.6"