From 7a9b4250aa4b544d1875dcb83d34ec4575b9aabf Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 7 Aug 2026 09:56:05 +1000 Subject: [PATCH] refactor(site): demui `` (#27719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Replace MUI `TextField` / Emotion theming in `IconField` with `InputGroup`, `Popover`, and Tailwind. `IconPickerField` already had the demui'd `InputGroup` + `Popover` implementation, so that was ported into the shared `IconField`. Per review feedback, `IconPickerField` is now removed and its call sites (the MCP server, provider, and OAuth app forms) use `IconField` directly. --- .../IconField/IconField.stories.tsx | 31 ++- site/src/components/IconField/IconField.tsx | 188 +++++++++++------- .../components/IconPickerField.tsx | 100 ---------- .../components/MCPServerFormFields.tsx | 12 +- .../ProvidersPage/components/ProviderForm.tsx | 8 +- .../OAuth2AppsSettingsPage/OAuth2AppForm.tsx | 18 +- 6 files changed, 174 insertions(+), 183 deletions(-) delete mode 100644 site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx diff --git a/site/src/components/IconField/IconField.stories.tsx b/site/src/components/IconField/IconField.stories.tsx index 698c1d6d35..f9412f2596 100644 --- a/site/src/components/IconField/IconField.stories.tsx +++ b/site/src/components/IconField/IconField.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { IconField } from "./IconField"; const meta: Meta = { title: "components/IconField", component: IconField, args: { - onPickEmoji: action("onPickEmoji"), + onPickEmoji: fn(), + onChange: fn(), }, }; @@ -26,3 +27,29 @@ export const IconSelected: Story = { value: "/icon/fedora.svg", }, }; + +export const WithHelperText: Story = { + args: { + helperText: "Paste an image URL or pick an emoji.", + }, +}; + +export const WithError: Story = { + args: { + error: true, + helperText: "Icon URL is too long.", + value: "https://example.com/very-long-icon-url.png", + }, +}; + +export const OpenPicker: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { + name: "Pick an emoji or icon", + }); + await userEvent.click(button); + await expect(button).toHaveAttribute("aria-expanded", "true"); + await expect(await screen.findByText("Smileys & People")).toBeVisible(); + }, +}; diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx index be48b8449b..a868cb4c98 100644 --- a/site/src/components/IconField/IconField.tsx +++ b/site/src/components/IconField/IconField.tsx @@ -1,95 +1,147 @@ -import { css, Global, useTheme } from "@emotion/react"; -import InputAdornment from "@mui/material/InputAdornment"; -import TextField, { type TextFieldProps } from "@mui/material/TextField"; -import { type FC, lazy, Suspense, useState } from "react"; -import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; +import { + type ComponentPropsWithRef, + type FC, + lazy, + type ReactNode, + Suspense, + useId, + useState, +} from "react"; +import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { Label } from "#/components/Label/Label"; import { Loader } from "#/components/Loader/Loader"; import { Popover, PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; - -type IconFieldProps = TextFieldProps & { - onPickEmoji: (value: string) => void; -}; +import { cn } from "#/utils/cn"; const EmojiPicker = lazy(() => import("./EmojiPicker")); +type IconFieldProps = Omit, "type"> & { + label?: ReactNode; + error?: boolean; + helperText?: ReactNode; + onPickEmoji: (value: string) => void; + /** Accepted for call-site compatibility with former MUI TextField usage. */ + fullWidth?: boolean; +}; + export const IconField: FC = ({ + id: idProp, + value, + label = "Icon", + error, + helperText, + disabled, + className, onPickEmoji, - ...textFieldProps + fullWidth: _fullWidth, + ...inputProps }) => { - if ( - typeof textFieldProps.value !== "string" && - typeof textFieldProps.value !== "undefined" - ) { - throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`); + if (typeof value !== "string" && typeof value !== "undefined") { + throw new Error(`Invalid icon value "${typeof value}"`); } - const theme = useTheme(); - const hasIcon = textFieldProps.value && textFieldProps.value !== ""; + const generatedId = useId(); + const id = idProp ?? generatedId; + const errorId = `${id}-error`; + const helperId = `${id}-helper`; const [open, setOpen] = useState(false); + const stringValue = value ?? ""; + const hasIcon = stringValue !== ""; return ( -
- +
+ {label ? ( + + ) : null} + + + + {hasIcon && ( + { - e.currentTarget.style.display = "none"; + src={stringValue} + className="max-w-full object-contain" + onError={(event) => { + event.currentTarget.style.display = "none"; }} - onLoad={(e) => { - e.currentTarget.style.display = "inline"; + onLoad={(event) => { + event.currentTarget.style.display = "inline"; }} /> - - ) : undefined, - }} - /> - - - - - - - - }> - { - const value = emoji.src ?? `/emojis/${emoji.unified}.png`; - onPickEmoji(value); - setOpen(false); - }} - /> - - - + + )} + + + + + event.stopPropagation()} + > + }> + { + const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; + onPickEmoji(picked); + setOpen(false); + }} + /> + + + + + + {helperText ? ( + + {helperText} + + ) : null} {/* - This component takes a long time to load (easily several seconds), so we diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx deleted file mode 100644 index 8f50854f2c..0000000000 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/IconPickerField.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { type FC, lazy, Suspense, useState } from "react"; -import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; -import { Button } from "#/components/Button/Button"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "#/components/InputGroup/InputGroup"; -import { Loader } from "#/components/Loader/Loader"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; - -const EmojiPicker = lazy(() => import("#/components/IconField/EmojiPicker")); - -interface IconPickerFieldProps { - id?: string; - value: string; - placeholder?: string; - disabled?: boolean; - onChange: (value: string) => void; -} - -export const IconPickerField: FC = ({ - id, - value, - placeholder, - disabled, - onChange, -}) => { - const [open, setOpen] = useState(false); - const hasIcon = value !== ""; - - return ( - - onChange(event.target.value)} - placeholder={placeholder} - disabled={disabled} - className="min-w-0 placeholder:text-content-disabled" - spellCheck={false} - /> - - {hasIcon && ( - - { - event.currentTarget.style.display = "none"; - }} - onLoad={(event) => { - event.currentTarget.style.display = "inline"; - }} - /> - - )} - - - - - event.stopPropagation()} - > - }> - { - const picked = emoji.src ?? `/emojis/${emoji.unified}.png`; - onChange(picked); - setOpen(false); - }} - /> - - - - - - ); -}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx index dddafadac1..3e8f310564 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -1,6 +1,7 @@ import type { FormikContextType } from "formik"; import { type FC, useId } from "react"; import { Button } from "#/components/Button/Button"; +import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; import { InputGroup, @@ -14,7 +15,6 @@ import { SelectValue, } from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; -import { IconPickerField } from "./IconPickerField"; import { MCPServerAuthSection } from "./MCPServerAuthSection"; import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection"; import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives"; @@ -150,11 +150,17 @@ export const MCPServerFormFields: FC = ({ /> - void form.setFieldValue("iconURL", value)} + label={null} + onChange={(event) => + void form.setFieldValue("iconURL", event.target.value) + } + onPickEmoji={(value) => + void form.setFieldValue("iconURL", value) + } disabled={isDisabled} /> diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index f701a4185c..a90d7a10a8 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -13,6 +13,7 @@ import { CodeExample } from "#/components/CodeExample/CodeExample"; import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; +import { IconField } from "#/components/IconField/IconField"; import { Label } from "#/components/Label/Label"; import { Link as DocsLink } from "#/components/Link/Link"; import { @@ -24,7 +25,6 @@ import { } from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; -import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { CredentialField } from "./CredentialField"; @@ -394,10 +394,12 @@ export const ProviderForm: FC = ({
Optional. URL or emoji shown for this provider.
- handleIconChange(event.target.value)} + onPickEmoji={handleIconChange} />
); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx index be100cb5e5..b561c40c1a 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx @@ -9,10 +9,10 @@ import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; +import { IconField } from "#/components/IconField/IconField"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; -import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField"; import { getFormHelpers, iconValidator, @@ -109,6 +109,12 @@ export const OAuth2AppForm: FC = ({ form.dirty && !form.isSubmitting, ); + const handleIconChange = (value: string) => { + void form.setFieldValue("icon", value); + void form.setFieldTouched("icon", true); + onIconChange?.(value); + }; + return (
@@ -134,15 +140,13 @@ export const OAuth2AppForm: FC = ({
Optional. URL or emoji shown for this application.
- { - void form.setFieldValue("icon", value); - void form.setFieldTouched("icon", true); - onIconChange?.(value); - }} + label={null} + onChange={(event) => handleIconChange(event.target.value)} + onPickEmoji={handleIconChange} /> {iconField.error ? (