diff --git a/.github/.linkspector.yml b/.github/.linkspector.yml index 1bbf60c200175..f5f99caf57708 100644 --- a/.github/.linkspector.yml +++ b/.github/.linkspector.yml @@ -25,5 +25,6 @@ ignorePatterns: - pattern: "docs.github.com" - pattern: "claude.ai" - pattern: "splunk.com" + - pattern: "stackoverflow.com/questions" aliveStatusCodes: - 200 diff --git a/docs/about/contributing/frontend.md b/docs/about/contributing/frontend.md index ceddc5c2ff819..a8a56df1baa02 100644 --- a/docs/about/contributing/frontend.md +++ b/docs/about/contributing/frontend.md @@ -66,6 +66,9 @@ All UI-related code is in the `site` folder. Key directories include: - **util** - Helper functions that can be used across the application - **static** - Static assets like images, fonts, icons, etc +Do not use barrel files. Imports should be directly from the file that defines +the value. + ## Routing We use [react-router](https://reactrouter.com/en/main) as our routing engine. diff --git a/site/package.json b/site/package.json index e3a99b9d8eebf..8d688b45c928b 100644 --- a/site/package.json +++ b/site/package.json @@ -17,7 +17,7 @@ "lint:check": " biome lint --error-on-warnings .", "lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx", "lint:knip": "knip", - "lint:fix": " biome lint --error-on-warnings --write . && knip --fix", + "lint:fix": "biome lint --error-on-warnings --write . && knip --fix", "lint:types": "tsc -p .", "playwright:install": "playwright install --with-deps chromium", "playwright:test": "playwright test --config=e2e/playwright.config.ts", diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 2786f35b0bf5e..2207f4e64686f 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -3,9 +3,35 @@ import { expect, screen, userEvent, waitFor, within } from "@storybook/test"; import { useState } from "react"; import { Combobox } from "./Combobox"; -const options = ["Option 1", "Option 2", "Option 3", "Another Option"]; +const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"]; -const ComboboxWithHooks = () => { +const advancedOptions = [ + { + displayName: "Go", + value: "go", + icon: "/icon/go.svg", + }, + { + displayName: "Gleam", + value: "gleam", + icon: "https://github.com/gleam-lang.png", + }, + { + displayName: "Kotlin", + value: "kotlin", + description: "Kotlin 2.1, OpenJDK 24, gradle", + icon: "/icon/kotlin.svg", + }, + { + displayName: "Rust", + value: "rust", + icon: "/icon/rust.svg", + }, +] as const; + +const ComboboxWithHooks = ({ + options = advancedOptions, +}: { options?: React.ComponentProps["options"] }) => { const [value, setValue] = useState(""); const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(""); @@ -34,17 +60,21 @@ const ComboboxWithHooks = () => { const meta: Meta = { title: "components/Combobox", component: Combobox, + args: { options: advancedOptions }, }; export default meta; type Story = StoryObj; -export const Default: Story = { - render: () => , +export const Default: Story = {}; + +export const SimpleOptions: Story = { + args: { + options: simpleOptions, + }, }; export const OpenCombobox: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -58,11 +88,7 @@ export const SelectOption: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); - await userEvent.click(screen.getByText("Option 1")); - - await waitFor(() => - expect(canvas.getByRole("button")).toHaveTextContent("Option 1"), - ); + await userEvent.click(screen.getByText("Go")); }, }; @@ -71,19 +97,13 @@ export const SearchAndFilter: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); - await userEvent.type(screen.getByRole("combobox"), "Another"); - await userEvent.click( - screen.getByRole("option", { name: "Another Option" }), - ); - + await userEvent.type(screen.getByRole("combobox"), "r"); await waitFor(() => { expect( - screen.getByRole("option", { name: "Another Option" }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("option", { name: "Option 1" }), + screen.queryByRole("option", { name: "Kotlin" }), ).not.toBeInTheDocument(); }); + await userEvent.click(screen.getByRole("option", { name: "Rust" })); }, }; @@ -92,16 +112,11 @@ export const EnterCustomValue: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); - await userEvent.type(screen.getByRole("combobox"), "Custom Value{enter}"); - - await waitFor(() => - expect(canvas.getByRole("button")).toHaveTextContent("Custom Value"), - ); + await userEvent.type(screen.getByRole("combobox"), "Swift{enter}"); }, }; export const NoResults: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -120,10 +135,11 @@ export const ClearSelectedOption: Story = { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); + // const goOption = screen.getByText("Go"); // First select an option - await userEvent.click(screen.getByRole("option", { name: "Option 1" })); + await userEvent.click(await screen.findByRole("option", { name: "Go" })); // Then clear it by selecting it again - await userEvent.click(screen.getByRole("option", { name: "Option 1" })); + await userEvent.click(await screen.findByRole("option", { name: "Go" })); await waitFor(() => expect(canvas.getByRole("button")).toHaveTextContent("Select option"), diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index fa15b6808a05e..bc0fa73eb9653 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -1,3 +1,4 @@ +import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { Command, @@ -12,22 +13,36 @@ import { PopoverContent, PopoverTrigger, } from "components/Popover/Popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import { Check, ChevronDown, CornerDownLeft } from "lucide-react"; -import type { FC, KeyboardEventHandler } from "react"; +import { Info } from "lucide-react"; +import { type FC, type KeyboardEventHandler, useState } from "react"; import { cn } from "utils/cn"; interface ComboboxProps { value: string; - options?: readonly string[]; + options?: Readonly>; placeholder?: string; - open: boolean; - onOpenChange: (open: boolean) => void; - inputValue: string; - onInputChange: (value: string) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + inputValue?: string; + onInputChange?: (value: string) => void; onKeyDown?: KeyboardEventHandler; onSelect: (value: string) => void; } +type ComboboxOption = { + icon?: string; + displayName: string; + value: string; + description?: string; +}; + export const Combobox: FC = ({ value, options = [], @@ -39,16 +54,37 @@ export const Combobox: FC = ({ onKeyDown, onSelect, }) => { + const [managedOpen, setManagedOpen] = useState(false); + const [managedInputValue, setManagedInputValue] = useState(""); + + const optionsMap = new Map( + options.map((option) => + typeof option === "string" + ? [option, { displayName: option, value: option }] + : [option.value, option], + ), + ); + const optionObjects = [...optionsMap.values()]; + const showIcons = optionObjects.some((it) => it.icon); + + const isOpen = open ?? managedOpen; + return ( - + { + setManagedOpen(newOpen); + onOpenChange?.(newOpen); + }} + > @@ -57,8 +93,11 @@ export const Combobox: FC = ({ { + setManagedInputValue(newValue); + onInputChange?.(newValue); + }} onKeyDown={onKeyDown} /> @@ -70,18 +109,40 @@ export const Combobox: FC = ({ - {options.map((option) => ( + {optionObjects.map((option) => ( { onSelect(currentValue === value ? "" : currentValue); }} > - {option} - {value === option && ( - + {showIcons && ( + )} + {option.displayName} +
+ {value === option.value && ( + + )} + {option.description && ( + + + + + + + {option.description} + + + + )} +
))}
diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 936e93034c705..359c2af7ccb17 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -617,7 +617,7 @@ export const MultiSelectCombobox = forwardRef< }} > {isLoading ? ( - <>{loadingIndicator} + loadingIndicator ) : ( <> {EmptyItem()} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index 6eb5f2f77d2a2..5e077df642855 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -56,24 +56,48 @@ export const Dropdown: Story = { type: "string", options: [ { - name: "Option 1", - value: { valid: true, value: "option1" }, - description: "this is option 1", - icon: "", + name: "Nissa, Worldsoul Speaker", + value: { valid: true, value: "nissa" }, + description: + "Zendikar still seems so far off, but Chandra is my home.", + icon: "/emojis/1f7e2.png", }, { - name: "Option 2", - value: { valid: true, value: "option2" }, - description: "this is option 2", - icon: "", + name: "Canopy Spider", + value: { valid: true, value: "spider" }, + description: + "It keeps the upper reaches of the forest free of every menace . . . except for the spider itself.", + icon: "/emojis/1f7e2.png", }, { - name: "Option 3", - value: { valid: true, value: "option3" }, - description: "this is option 3", - icon: "", + name: "Ajani, Nacatl Pariah", + value: { valid: true, value: "ajani" }, + description: "His pride denied him; his brother did not.", + icon: "/emojis/26aa.png", + }, + { + name: "Glowing Anemone", + value: { valid: true, value: "anemone" }, + description: "Beautiful to behold, terrible to be held.", + icon: "/emojis/1f535.png", + }, + { + name: "Springmantle Cleric", + value: { valid: true, value: "cleric" }, + description: "Hope and courage bloom in her wake.", + icon: "/emojis/1f7e2.png", + }, + { + name: "Aegar, the Freezing Flame", + value: { valid: true, value: "aegar" }, + description: + "Though Phyrexian machines could adapt to extremes of heat or cold, they never figured out how to adapt to both at once.", + icon: "/emojis/1f308.png", }, ], + styling: { + placeholder: "Select a creature", + }, }, }, }; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx index 43e75af1d2f0e..e3bfd8dc80635 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx @@ -191,7 +191,7 @@ describe("DynamicParameter", () => { }); }); - describe("Select Parameter", () => { + describe("dropdown parameter", () => { const mockSelectParameter = createMockParameter({ name: "select_param", display_name: "Select Parameter", @@ -221,19 +221,6 @@ describe("DynamicParameter", () => { ], }); - it("renders select parameter with options", () => { - render( - , - ); - - expect(screen.getByText("Select Parameter")).toBeInTheDocument(); - expect(screen.getByRole("combobox")).toBeInTheDocument(); - }); - it("displays all options when opened", async () => { render( { />, ); - const select = screen.getByRole("combobox"); + const select = screen.getByRole("button"); await waitFor(async () => { await userEvent.click(select); }); @@ -263,7 +250,7 @@ describe("DynamicParameter", () => { />, ); - const select = screen.getByRole("combobox"); + const select = screen.getByRole("button"); await waitFor(async () => { await userEvent.click(select); }); @@ -275,26 +262,6 @@ describe("DynamicParameter", () => { expect(mockOnChange).toHaveBeenCalledWith("option2"); }); - - it("displays option icons when provided", async () => { - render( - , - ); - - const select = screen.getByRole("combobox"); - await waitFor(async () => { - await userEvent.click(select); - }); - - const icons = screen.getAllByRole("img"); - expect( - icons.some((icon) => icon.getAttribute("src") === "/icon2.png"), - ).toBe(true); - }); }); describe("Radio Parameter", () => { @@ -829,7 +796,7 @@ describe("DynamicParameter", () => { />, ); - expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); }); it("handles null/undefined values", () => { diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index ac0df20355205..5d92fb6d6ae6d 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -7,6 +7,7 @@ import type { import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; import { Checkbox } from "components/Checkbox/Checkbox"; +import { Combobox } from "components/Combobox/Combobox"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; @@ -16,13 +17,6 @@ import { type Option, } from "components/MultiSelectCombobox/MultiSelectCombobox"; import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "components/Select/Select"; import { Slider } from "components/Slider/Slider"; import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; @@ -434,43 +428,17 @@ const ParameterField: FC = ({ }) => { switch (parameter.form_type) { case "dropdown": { - const EMPTY_VALUE_PLACEHOLDER = "__EMPTY_STRING__"; - const selectValue = value === "" ? EMPTY_VALUE_PLACEHOLDER : value; - const handleSelectChange = (newValue: string) => { - onChange(newValue === EMPTY_VALUE_PLACEHOLDER ? "" : newValue); - }; - return ( - + onChange(value)} + options={parameter.options.map((option) => ({ + icon: option.icon, + displayName: option.name, + value: option.value.value, + description: option.description, + }))} + /> ); }