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, + }))} + /> ); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy