diff --git a/site/package.json b/site/package.json index 491655c055..9f76b4211f 100644 --- a/site/package.json +++ b/site/package.json @@ -90,7 +90,6 @@ "pretty-bytes": "6.1.1", "radix-ui": "1.6.7", "react": "19.2.8", - "react-color": "2.19.3", "react-confetti": "6.4.0", "react-day-picker": "9.14.0", "react-dom": "19.2.8", @@ -146,7 +145,6 @@ "@types/node": "20.19.41", "@types/novnc__novnc": "1.5.0", "@types/react": "19.2.18", - "@types/react-color": "3.0.13", "@types/react-dom": "19.2.5", "@types/react-syntax-highlighter": "15.5.13", "@types/react-virtualized-auto-sizer": "1.0.8", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 4d6a0222bf..9c9fe1cfd4 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -166,9 +166,6 @@ importers: react: specifier: 19.2.8 version: 19.2.8 - react-color: - specifier: 2.19.3 - version: 2.19.3(react@19.2.8) react-confetti: specifier: 6.4.0 version: 6.4.0(react@19.2.8) @@ -329,9 +326,6 @@ importers: '@types/react': specifier: 19.2.18 version: 19.2.18 - '@types/react-color': - specifier: 3.0.13 - version: 3.0.13(@types/react@19.2.18) '@types/react-dom': specifier: 19.2.5 version: 19.2.5(@types/react@19.2.18) @@ -921,11 +915,6 @@ packages: '@iconify/utils@3.1.7': resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==, tarball: https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz} - '@icons/material@0.2.4': - resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==, tarball: https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz} - peerDependencies: - react: '*' - '@inquirer/confirm@3.2.0': resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==, tarball: https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz} engines: {node: '>=18'} @@ -3376,11 +3365,6 @@ packages: '@types/range-parser@1.2.4': resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==, tarball: https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz} - '@types/react-color@3.0.13': - resolution: {integrity: sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==, tarball: https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz} - peerDependencies: - '@types/react': '*' - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz} peerDependencies: @@ -3404,11 +3388,6 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==, tarball: https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz} - '@types/reactcss@1.2.13': - resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==, tarball: https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz} - peerDependencies: - '@types/react': '*' - '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz} @@ -5151,9 +5130,6 @@ packages: engines: {node: '>= 20'} hasBin: true - material-colors@1.2.6: - resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==, tarball: https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, tarball: https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz} engines: {node: '>= 0.4'} @@ -5741,11 +5717,6 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz} engines: {node: '>= 0.8'} - react-color@2.19.3: - resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==, tarball: https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz} - peerDependencies: - react: '*' - react-confetti@6.4.0: resolution: {integrity: sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==, tarball: https://registry.npmjs.org/react-confetti/-/react-confetti-6.4.0.tgz} engines: {node: '>=16'} @@ -5890,11 +5861,6 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==, tarball: https://registry.npmjs.org/react/-/react-19.2.8.tgz} engines: {node: '>=0.10.0'} - reactcss@1.2.3: - resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==, tarball: https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz} - peerDependencies: - react: '*' - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz} @@ -6313,9 +6279,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, tarball: https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz} - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==, tarball: https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz} - tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz} engines: {node: '>=18'} @@ -7308,10 +7271,6 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 - '@icons/material@0.2.4(react@19.2.8)': - dependencies: - react: 19.2.8 - '@inquirer/confirm@3.2.0': dependencies: '@inquirer/core': 9.2.1 @@ -9591,11 +9550,6 @@ snapshots: '@types/range-parser@1.2.4': {} - '@types/react-color@3.0.13(@types/react@19.2.18)': - dependencies: - '@types/react': 19.2.18 - '@types/reactcss': 1.2.13(@types/react@19.2.18) - '@types/react-dom@18.3.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -9623,10 +9577,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/reactcss@1.2.13(@types/react@19.2.18)': - dependencies: - '@types/react': 19.2.18 - '@types/resolve@1.20.6': {} '@types/semver@7.8.0': {} @@ -11438,8 +11388,6 @@ snapshots: marked@17.0.5: {} - material-colors@1.2.6: {} - math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -12385,17 +12333,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-color@2.19.3(react@19.2.8): - dependencies: - '@icons/material': 0.2.4(react@19.2.8) - lodash: 4.18.1 - lodash-es: 4.18.1 - material-colors: 1.2.6 - prop-types: 15.8.1 - react: 19.2.8 - reactcss: 1.2.3(react@19.2.8) - tinycolor2: 1.6.0 - react-confetti@6.4.0(react@19.2.8): dependencies: react: 19.2.8 @@ -12563,11 +12500,6 @@ snapshots: react@19.2.8: {} - reactcss@1.2.3(react@19.2.8): - dependencies: - lodash: 4.18.1 - react: 19.2.8 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -13085,8 +13017,6 @@ snapshots: tinybench@2.9.0: {} - tinycolor2@1.6.0: {} - tinyexec@1.3.0: {} tinyglobby@0.2.17: diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.stories.tsx index cc676c9696..1677853116 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; +import { fn, userEvent, within } from "storybook/test"; import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog"; const meta: Meta = { @@ -24,26 +24,10 @@ const Example: Story = {}; export { Example as AnnouncementBannerDialog }; export const EditsMessage: Story = { - play: async ({ args }) => { + play: async () => { const body = within(document.body); const message = await body.findByLabelText("Message"); - await expect(message).toHaveValue( - "The beep-bop will be boop-beeped on Saturday at 12AM PST.", - ); - await userEvent.clear(message); await userEvent.type(message, "Scheduled maintenance tonight."); - await userEvent.click(body.getByRole("button", { name: "Update" })); - await expect(args.onUpdate).toHaveBeenCalledWith( - expect.objectContaining({ message: "Scheduled maintenance tonight." }), - ); - }, -}; - -export const CancelClosesDialog: Story = { - play: async ({ args }) => { - const body = within(document.body); - await userEvent.click(await body.findByRole("button", { name: "Cancel" })); - await expect(args.onCancel).toHaveBeenCalled(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.test.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.test.tsx new file mode 100644 index 0000000000..5a80dc8f22 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.test.tsx @@ -0,0 +1,85 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { BannerConfig } from "#/api/typesGenerated"; +import { render } from "#/testHelpers/renderHelpers"; +import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog"; + +const banner: BannerConfig = { + enabled: true, + message: "Hello from the banner", + background_color: "#ffaff3", +}; + +describe("AnnouncementBannerDialog", () => { + it("submits an edited message", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(async () => undefined); + render( + undefined} + onUpdate={onUpdate} + />, + ); + + const message = screen.getByRole("textbox", { name: "Message" }); + await user.clear(message); + await user.type(message, "Scheduled maintenance tonight."); + await user.click(screen.getByRole("button", { name: "Update" })); + + expect(onUpdate).toHaveBeenCalledWith({ + message: "Scheduled maintenance tonight.", + background_color: "#ffaff3", + }); + }); + + it("replaces an incomplete hex draft with a preset background color", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(async () => undefined); + render( + undefined} + onUpdate={onUpdate} + />, + ); + + const hex = screen.getByRole("textbox", { name: "Hex" }); + await user.clear(hex); + await user.type(hex, "abc"); + await user.click(screen.getByRole("button", { name: "#8b5cf6" })); + expect(hex).toHaveValue("8b5cf6"); + await user.click(screen.getByRole("button", { name: "Update" })); + + expect(onUpdate).toHaveBeenCalledWith({ + message: "Hello from the banner", + background_color: "#8b5cf6", + }); + }); + + it("restores and submits the last valid typed color after an incomplete edit", async () => { + const user = userEvent.setup(); + const onUpdate = vi.fn(async () => undefined); + render( + undefined} + onUpdate={onUpdate} + />, + ); + + const hex = screen.getByRole("textbox", { name: "Hex" }); + await user.clear(hex); + await user.type(hex, "112233"); + await user.clear(hex); + await user.type(hex, "abc"); + await user.tab(); + expect(hex).toHaveValue("112233"); + await user.click(screen.getByRole("button", { name: "Update" })); + + expect(onUpdate).toHaveBeenCalledWith({ + message: "Hello from the banner", + background_color: "#112233", + }); + }); +}); diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx index 51c3d4cca8..213bae6856 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerDialog.tsx @@ -1,9 +1,6 @@ import { cn } from "cn"; import { useFormik } from "formik"; -import { useState } from "react"; -import { SliderPicker, TwitterPicker } from "react-color"; import type { BannerConfig } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; import { Dialog, DialogActions, @@ -15,8 +12,8 @@ import { import { Label } from "#/components/Label/Label"; import { Textarea } from "#/components/Textarea/Textarea"; import { AnnouncementBannerView } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBannerView"; -import { useTheme } from "#/theme/context"; import { getFormHelpers } from "#/utils/formUtils"; +import { BannerColorPicker } from "./BannerColorPicker"; type AnnouncementBannerDialogProps = { banner: BannerConfig; @@ -27,7 +24,6 @@ type AnnouncementBannerDialogProps = { export const AnnouncementBannerDialog: React.FC< AnnouncementBannerDialogProps > = ({ banner, onCancel, onUpdate }) => { - const theme = useTheme(); const isCreating = banner.message === ""; const bannerForm = useFormik<{ @@ -47,7 +43,6 @@ export const AnnouncementBannerDialog: React.FC< const messageHelperId = `${messageField.id}-helper`; const messageErrorId = `${messageField.id}-error`; - const [showHuePicker, setShowHuePicker] = useState(false); const previewMessage = bannerForm.values.message.trim(); return ( @@ -121,67 +116,15 @@ export const AnnouncementBannerDialog: React.FC<

Background color

-
- {showHuePicker ? ( - { - await bannerForm.setFieldValue( - "background_color", - color.hex, - ); - }} - /> - ) : ( - { - await bannerForm.setFieldValue( - "background_color", - color.hex, - ); - }} - triangle="hide" - colors={[ - "#8b5cf6", - "#d94a5d", - "#f78da7", - "#d65d0f", - "#ff6900", - "#fcb900", - "#0693e3", - - "#8ed1fc", - "#4cd473", - "#abb8c3", - ]} - styles={{ - default: { - input: { - color: "white", - backgroundColor: theme.palette.background.default, - }, - body: { - backgroundColor: "transparent", - color: "white", - padding: 0, - }, - card: { - backgroundColor: "transparent", - }, - }, - }} - /> - )} -
- -
-
+ { + void bannerForm.setFieldValue( + "background_color", + backgroundColor, + ); + }} + /> diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/BannerColorPicker.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/BannerColorPicker.tsx new file mode 100644 index 0000000000..8e092d2397 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/BannerColorPicker.tsx @@ -0,0 +1,86 @@ +import { cn } from "cn"; +import { useId, useState } from "react"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { Label } from "#/components/Label/Label"; +import { isHexColor } from "#/utils/colors"; + +const PRESET_COLORS = [ + "#8b5cf6", + "#d94a5d", + "#f78da7", + "#d65d0f", + "#ff6900", + "#fcb900", + "#0693e3", + "#8ed1fc", + "#4cd473", + "#abb8c3", +] as const; + +type BannerColorPickerProps = { + color: string; + onChange: (color: string) => void; +}; + +export const BannerColorPicker: React.FC = ({ + color, + onChange, +}) => { + const [hexDraft, setHexDraft] = useState(undefined); + const hexId = useId(); + + return ( +
+
+ {PRESET_COLORS.map((preset) => { + const selected = preset.toLowerCase() === color.toLowerCase(); + return ( +
+
+ + + # + { + const digits = event.target.value.replaceAll("#", "").slice(0, 6); + setHexDraft(digits); + const next = `#${digits}`; + if (isHexColor(next)) { + onChange(next); + } + }} + onBlur={() => setHexDraft(undefined)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> + +
+
+ ); +};