From c8bd6442e6b6eee8d08a761452960f7f77e334a9 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 20 Aug 2026 09:08:49 -0700 Subject: [PATCH] Uniform modal component (#5741) * add reusable Modal component, migrate all modals off ModalWrapper, drop duplicate dark overlays * migrate all modals to uniform Modal component with normalized light/dark styles * fix modal light-mode focus, content styles, spacing, scroll, and save-bar overlap * align modal footer right when only one action button * fix embed chat widgets page overflow * fix embed modal toggles not saving and modal scroll spacing * normalize tag inputs and code snippet modal spacing * fix community hub publish success modal views * use static translation keys for community hub publish success view * close nested modals one at a time on escape via shared useModalEscape hook * fix copy chat link modal reopening every click --------- Co-authored-by: Timothy Carambat --- .../src/components/ChangeWarning/index.jsx | 84 ++-- .../PublishEntityModal/AgentFlows/index.jsx | 166 +++----- .../SlashCommands/index.jsx | 194 ++++----- .../SystemPrompts/index.jsx | 180 +++----- .../CommunityHub/PublishEntityModal/index.jsx | 101 ++++- .../UnauthenticatedHubModal/index.jsx | 45 +- .../KeyboardShortcutsHelp/index.jsx | 34 +- .../src/components/ModalWrapper/index.jsx | 35 -- .../Modals/DisplayRecoveryCodeModal/index.jsx | 100 +++-- .../Connectors/Github/index.jsx | 6 +- .../Connectors/Gitlab/index.jsx | 6 +- .../Directory/NewFolderModal/index.jsx | 95 ++--- .../Documents/Directory/index.jsx | 15 +- .../Documents/WorkspaceDirectory/index.jsx | 159 ++++--- .../Modals/ManageWorkspace/index.jsx | 95 ++--- .../src/components/Modals/NewWorkspace.jsx | 92 ++-- .../Modals/Password/MultiUserAuth.jsx | 10 +- .../Modals/Password/SingleUserAuth.jsx | 10 +- .../UserMenu/AccountModal/index.jsx | 262 +++++------- .../ChatHistory/Citation/index.jsx | 133 +++--- .../ChatSettingsMenu/CopyLinkToChat/index.jsx | 62 +-- .../FileUploadWarningModal/index.jsx | 128 +++--- .../MemoriesSidebar/MemoryModal/index.jsx | 94 ++--- .../LLMSelector/SetupProvider/index.jsx | 90 ++-- .../SlashPresets/AddPresetModal.jsx | 159 +++---- .../SlashPresets/EditPresetModal.jsx | 161 +++---- .../MobileCitationModal/index.jsx | 6 +- .../src/components/WorkspaceChat/index.jsx | 59 ++- frontend/src/components/lib/Modal/index.jsx | 323 ++++++++++++++ frontend/src/hooks/useModalEscape.js | 46 ++ frontend/src/index.css | 10 +- .../AgentClarifyingQuestions.jsx | 14 +- .../AgentSkillSettings/AgentSkillReranker.jsx | 12 +- .../AgentSkillSettings/MaxToolCallStack.jsx | 10 +- .../Admin/Agents/AgentSkillSettings/index.jsx | 37 +- .../SQLConnectionModal.jsx | 398 ++++++++---------- .../Admin/ExperimentalFeatures/index.jsx | 180 ++++---- .../Invitations/NewInviteModal/index.jsx | 200 ++++----- .../src/pages/Admin/Invitations/index.jsx | 6 +- .../AddVariableModal/index.jsx | 147 +++---- .../VariableRow/EditVariableModal/index.jsx | 153 +++---- .../VariableRow/index.jsx | 6 +- .../Admin/SystemPromptVariables/index.jsx | 6 +- .../pages/Admin/Users/NewUserModal/index.jsx | 204 ++++----- .../Users/UserRow/EditUserModal/index.jsx | 198 ++++----- .../src/pages/Admin/Users/UserRow/index.jsx | 6 +- frontend/src/pages/Admin/Users/index.jsx | 12 +- .../Workspaces/NewWorkspaceModal/index.jsx | 95 ++--- frontend/src/pages/Admin/Workspaces/index.jsx | 6 +- .../ApiKeys/NewApiKeyModal/index.jsx | 189 ++++----- .../pages/GeneralSettings/ApiKeys/index.jsx | 6 +- .../NewBrowserExtensionApiKeyModal/index.jsx | 132 +++--- .../BrowserExtensionApiKey/index.jsx | 6 +- .../EmbedChats/ChatRow/index.jsx | 63 ++- .../EmbedRow/CodeSnippetModal/index.jsx | 66 ++- .../EmbedRow/EditEmbedModal/index.jsx | 160 +++---- .../EmbedConfigs/EmbedRow/index.jsx | 10 +- .../EmbedConfigs/NewEmbedModal/index.jsx | 221 ++++------ .../ChatEmbedWidgets/EmbedConfigs/index.jsx | 6 +- .../ChatEmbedWidgets/index.jsx | 6 +- .../GeneralSettings/Chats/ChatRow/index.jsx | 39 +- .../EmbeddingPreference/index.jsx | 6 +- .../EmbeddingTextSplitterPreference/index.jsx | 6 +- .../ConnectionModal/index.jsx | 10 +- .../LLMProviderModelPicker/index.jsx | 103 +++-- .../ModelRouters/NewRouterModal/index.jsx | 160 +++---- .../RuleBuilder/RuleForm/index.jsx | 189 ++++----- .../ScheduledJobs/JobFormModal/index.jsx | 93 ++-- .../GeneralSettings/ScheduledJobs/index.jsx | 6 +- .../GeneralSettings/VectorDatabase/index.jsx | 6 +- .../src/pages/Invite/NewUserModal/index.jsx | 110 ++--- frontend/src/pages/Invite/index.jsx | 6 +- .../AgentLLMSelection/AgentLLMItem/index.jsx | 86 ++-- .../WorkspaceLLMItem/index.jsx | 88 ++-- .../Members/AddMemberModal/index.jsx | 163 ++++--- .../pages/WorkspaceSettings/Members/index.jsx | 6 +- 76 files changed, 2982 insertions(+), 3610 deletions(-) delete mode 100644 frontend/src/components/ModalWrapper/index.jsx create mode 100644 frontend/src/components/lib/Modal/index.jsx create mode 100644 frontend/src/hooks/useModalEscape.js diff --git a/frontend/src/components/ChangeWarning/index.jsx b/frontend/src/components/ChangeWarning/index.jsx index 95ca1c49..cc4704fe 100644 --- a/frontend/src/components/ChangeWarning/index.jsx +++ b/frontend/src/components/ChangeWarning/index.jsx @@ -1,4 +1,11 @@ -import { Warning, X } from "@phosphor-icons/react"; +import { Warning } from "@phosphor-icons/react"; +import { + ModalHeader, + ModalBody, + ModalFooter, + ModalSecondaryButton, + ModalDangerButton, +} from "@/components/lib/Modal"; export default function ChangeWarningModal({ warningText = "", @@ -6,56 +13,37 @@ export default function ChangeWarningModal({ onConfirm, }) { return ( -
-
-
- -

+
+ + WARNING - This action is irreversible -

-
- -
-
-
-

- {warningText.split("\\n").map((line, index) => ( - - {line} -
-
- ))} -
-
- Are you sure you want to proceed? -

-
-
-
- - -
+ +
); } diff --git a/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx b/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx index 05ace1ac..ee563851 100644 --- a/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx +++ b/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx @@ -2,19 +2,23 @@ import { useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import CommunityHub from "@/models/communityHub"; import showToast from "@/utils/toast"; -import paths from "@/utils/paths"; import { X, CaretRight } from "@phosphor-icons/react"; import { BLOCK_INFO } from "@/pages/Admin/AgentBuilder/BlockList"; -import { Link } from "react-router-dom"; +import { + ModalHeader, + ModalPrimaryButton, + ModalLabel, + ModalHint, + ModalInput, + ModalTextarea, +} from "@/components/lib/Modal"; -export default function AgentFlows({ entity }) { +export default function AgentFlows({ entity, onSuccess }) { const { t } = useTranslation(); const formRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(""); - const [isSuccess, setIsSuccess] = useState(false); - const [itemId, setItemId] = useState(null); const [expandedStep, setExpandedStep] = useState(null); const handleSubmit = async (e) => { @@ -39,8 +43,7 @@ export default function AgentFlows({ entity }) { const { success, error, itemId } = await CommunityHub.createAgentFlow(data); if (!success) throw new Error(error); - setItemId(itemId); - setIsSuccess(true); + onSuccess(itemId); } catch (error) { console.error("Failed to publish agent flow:", error); showToast(`Failed to publish agent flow: ${error.message}`, "error", { @@ -67,99 +70,58 @@ export default function AgentFlows({ entity }) { setTags(tags.filter((tag) => tag !== tagToRemove)); }; - if (isSuccess) { - return ( -
-
-

- {t("community_hub.publish.agent_flow.success_title")} -

-

- {t("community_hub.publish.agent_flow.success_description")} -

-

- {t("community_hub.publish.agent_flow.success_thank_you")} -

- - {t("community_hub.publish.agent_flow.view_on_hub")} - -
-
- ); - } - return ( <> -
-

- {t("community_hub.publish.agent_flow.modal_title")} -

+
+
-
- -
- {t("community_hub.publish.agent_flow.name_description")} -
- -
+ +
- -
- {t("community_hub.publish.agent_flow.description_description")} -
- -
-
- - -
-
-
-
- - -
- - - - + +
+ + + + + + + + + {t("chat_window.cancel")} + + + {t("chat_window.save")} + + + +
); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx index 0f97f444..41f40dc1 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx @@ -1,6 +1,14 @@ import { useState, useEffect } from "react"; -import { X } from "@phosphor-icons/react"; -import ModalWrapper from "@/components/ModalWrapper"; +import Modal, { + ModalHeader, + ModalBody, + ModalFooter, + ModalPrimaryButton, + ModalSecondaryButton, + ModalDangerButton, + ModalInput, + ModalTextarea, +} from "@/components/lib/Modal"; import { CMD_REGEX } from "./constants"; export default function EditPresetModal({ @@ -46,110 +54,53 @@ export default function EditPresetModal({ }; return ( - -
-
-
-

- Edit Preset -

-
- -
-
- -
-
-
- -
- / - -
-
-
- - -
-
- - -
-
-
-
- -
- - -
-
- -
-
-
+ {deleting ? "Deleting..." : "Delete Preset"} + +
+ + Cancel + + Save +
+ + + ); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx index 1ba07ec5..f0dcbde5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx @@ -1,6 +1,6 @@ import { X } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; -import ModalWrapper from "@/components/ModalWrapper"; +import Modal from "@/components/lib/Modal"; import { combineLikeSources } from "../../ChatHistory/Citation"; import SourceDetailView from "./SourceDetailView"; import SourceItem from "../SourceItem"; @@ -16,7 +16,7 @@ export default function MobileCitationModal({ const { t } = useTranslation(); return ( - +
{selectedSource ? ( @@ -51,6 +51,6 @@ export default function MobileCitationModal({ )}
- + ); } diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx index ee3c6d7e..86518778 100644 --- a/frontend/src/components/WorkspaceChat/index.jsx +++ b/frontend/src/components/WorkspaceChat/index.jsx @@ -3,7 +3,11 @@ import Workspace from "@/models/workspace"; import LoadingChat from "./LoadingChat"; import ChatContainer from "./ChatContainer"; import paths from "@/utils/paths"; -import ModalWrapper from "../ModalWrapper"; +import Modal, { + ModalHeader, + ModalBody, + ModalFooter, +} from "@/components/lib/Modal"; import { useNavigate, useParams } from "react-router-dom"; import { DnDFileUploaderProvider, @@ -81,35 +85,30 @@ export default function WorkspaceChat({ loading, workspace }) { return ( <> {loading === false && !workspace && ( - -
-
-
- -

- Workspace not found -

-
-
-
-

- The workspace you're looking for is not available. It may have - been deleted or you may not have access to it. -

-
- -
-
+ + + + Workspace not found + + } + /> + +

+ The workspace you're looking for is not available. It may have + been deleted or you may not have access to it. +

+
+ + + Return to homepage + + +
)} diff --git a/frontend/src/components/lib/Modal/index.jsx b/frontend/src/components/lib/Modal/index.jsx new file mode 100644 index 00000000..1925043b --- /dev/null +++ b/frontend/src/components/lib/Modal/index.jsx @@ -0,0 +1,323 @@ +import { Children } from "react"; +import { createPortal } from "react-dom"; +import { X } from "@phosphor-icons/react"; +import { useModalEscape } from "@/hooks/useModalEscape"; + +/** @type {Record} max-width per size, matched to the Figma modal frames */ +const SIZE_CLASSES = { + sm: "max-w-[400px]", + md: "max-w-[500px]", + lg: "max-w-[640px]", + xl: "max-w-[868px]", +}; + +/** + * Single, reusable modal shell for the app. Renders a centered, blurred + * backdrop and the uniform modal card, then portals to the app root. Compose + * the inner layout with the exported `ModalHeader`, `ModalBody`, and + * `ModalFooter` pieces - the body stays unopinionated and accepts whatever JSX + * you need. + * + * For modals that are not card-shaped (lightboxes, citations, etc.) pass + * `variant="bare"` to skip the card chrome and just center the children. + * + * @param {Object} props - Component props + * @param {import("react").ReactNode} props.children - The DOM/JSX to render inside the modal + * @param {boolean} props.isOpen - Renders the modal when true + * @param {() => void} [props.onClose] - Called when the modal requests to close (e.g. Escape key) + * @param {"sm"|"md"|"lg"|"xl"} [props.size="md"] - Card max-width preset (400/500/640/868px) + * @param {"default"|"bare"} [props.variant="default"] - `default` renders the uniform card; `bare` centers raw children + * @param {string} [props.className] - Extra classes appended to the backdrop container (e.g. a higher `z-` to clear a page's fixed top bar) + * @param {boolean} [props.closeOnEsc=true] - Whether pressing Escape calls `onClose`. Some modals require a forced choice and should opt out. + * @param {boolean} [props.noPortal=false] - Render inline instead of portaling to #root. Used for sub-DOM modals that must render as a child element. + * Note: this can impact the backdrop presentation due to conflicting DOM positions, so double check it renders as desired. + */ +export default function Modal({ + children, + isOpen, + onClose, + size = "md", + variant = "default", + className = "", + closeOnEsc = true, + noPortal = false, +}) { + useModalEscape(isOpen && closeOnEsc, onClose); + + if (!isOpen) return null; + + const backdrop = + `bg-black/60 backdrop-blur-sm fixed top-0 left-0 outline-none w-screen h-screen flex items-center justify-center z-99 ${className}`.trim(); + + if (variant === "bare") { + const bare =
{children}
; + if (noPortal) return bare; + return createPortal(bare, document.getElementById("root")); + } + + const content = ( +
+
+ {children} +
+
+ ); + + if (noPortal) return content; + return createPortal(content, document.getElementById("root")); +} + +/** + * Modal header: title (required), optional muted subtitle, and a close button. + * + * @param {Object} props - Component props + * @param {import("react").ReactNode} props.title - The header title + * @param {import("react").ReactNode} [props.subtitle] - Muted helper line under the title + * @param {() => void} [props.onClose] - Renders the close button when provided + * @param {import("react").ReactNode} [props.children] - Extra header content rendered below the subtitle + */ +export function ModalHeader({ title, subtitle, onClose, children }) { + return ( +
+
+

+ {title} +

+ {onClose && ( + + )} +
+ {subtitle && ( +

{subtitle}

+ )} + {children} +
+ ); +} + +/** + * Modal body: the unopinionated content region. Stacks children with the + * standard field spacing; pass `className` to override the layout. + * + * @param {Object} props - Component props + * @param {import("react").ReactNode} props.children - Body content + * @param {string} [props.className] - Extra classes appended to the body container + */ +export function ModalBody({ children, className = "" }) { + return ( +
+ {children} +
+ ); +} + +/** + * Modal footer: the action row. With multiple actions it defaults to + * `justify-between` (Cancel left, primary right) to match the design; with a + * single action it right-aligns. Override with `className` when needed. + * + * @param {Object} props - Component props + * @param {import("react").ReactNode} props.children - Footer actions + * @param {string} [props.className] - Extra classes appended to the footer container + */ +export function ModalFooter({ children, className = "" }) { + const align = + Children.count(children) > 1 ? "justify-between" : "justify-end"; + return ( +
+ {children} +
+ ); +} + +const FIELD_BASE = + "w-full text-sm rounded-lg outline-none bg-zinc-800 border border-zinc-800 text-zinc-100 placeholder:text-zinc-400 light:bg-white light:border-slate-300 light:text-slate-900 light:placeholder:text-slate-400 focus:border-sky-500 light:focus:border-sky-500 disabled:opacity-50 disabled:cursor-not-allowed"; + +/** + * Field label matching the Figma input label (Medium 14). + * + * @param {Object} props - Component props (forwarded to the label element) + * @param {boolean} [props.optional] - Appends a muted "(Optional)" suffix + * @param {string} [props.className] - Extra classes appended to the label + */ +export function ModalLabel({ children, optional, className = "", ...props }) { + return ( + + ); +} + +/** + * Muted helper/hint line shown under a label or field (Regular 12). + * + * @param {Object} props - Component props + * @param {string} [props.className] - Extra classes appended to the hint + */ +export function ModalHint({ children, className = "" }) { + return ( +

+ {children} +

+ ); +} + +/** + * Labeled text input - the most-reused modal field. Renders label, input, and + * optional hint with the uniform field styling. Extra props pass to ``. + * + * @param {Object} props - Component props (forwarded to the input element) + * @param {import("react").ReactNode} [props.label] - Field label + * @param {import("react").ReactNode} [props.hint] - Helper line under the input + * @param {boolean} [props.optional] - Marks the label "(Optional)" + * @param {import("react").ReactNode} [props.leading] - Inline content rendered before the input (e.g. a "/" prefix) + * @param {string} [props.className] - Extra classes appended to the input + */ +export function ModalInput({ + label, + hint, + optional, + leading, + className = "", + id, + name, + ...props +}) { + const inputId = id || name; + return ( +
+ {label && ( + + {label} + + )} +
+ {leading && ( + + {leading} + + )} + +
+ {hint && {hint}} +
+ ); +} + +/** + * Labeled textarea matching `ModalInput`. Extra props pass to `