From e012a2f2eb37ce30d1c961a7944121140cfecedf Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 2 Jul 2025 22:08:30 +0000 Subject: [PATCH 01/21] feat: search dynamic parameter dropdowns --- docs/about/contributing/frontend.md | 3 + .../SearchableSelect.stories.tsx | 259 +++++++++++++++ .../SearchableSelect/SearchableSelect.tsx | 263 +++++++++++++++ .../__tests__/SearchableSelect.test.tsx | 306 ++++++++++++++++++ site/src/components/SearchableSelect/index.ts | 7 + .../DynamicParameter.stories.tsx | 37 ++- .../DynamicParameter/DynamicParameter.tsx | 45 +-- 7 files changed, 899 insertions(+), 21 deletions(-) create mode 100644 site/src/components/SearchableSelect/SearchableSelect.stories.tsx create mode 100644 site/src/components/SearchableSelect/SearchableSelect.tsx create mode 100644 site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx create mode 100644 site/src/components/SearchableSelect/index.ts 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/src/components/SearchableSelect/SearchableSelect.stories.tsx b/site/src/components/SearchableSelect/SearchableSelect.stories.tsx new file mode 100644 index 0000000000000..f6e79e9a77408 --- /dev/null +++ b/site/src/components/SearchableSelect/SearchableSelect.stories.tsx @@ -0,0 +1,259 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { + SearchableSelect, + SearchableSelectContent, + SearchableSelectItem, + SearchableSelectTrigger, + SearchableSelectValue, +} from "./SearchableSelect"; +import { GitBranch, Globe, Lock, Users } from "lucide-react"; + +const meta: Meta = { + title: "components/SearchableSelect", + component: SearchableSelect, + args: { + placeholder: "Select an option", + }, +}; + +export default meta; +type Story = StoryObj; + +const SimpleOptions = () => { + const [value, setValue] = useState(""); + + return ( + + + + + + Option 1 + Option 2 + Option 3 + Option 4 + + + ); +}; + +export const Default: Story = { + render: () => , +}; + +const ManyOptionsExample = () => { + const [value, setValue] = useState(""); + const options = Array.from({ length: 50 }, (_, i) => ({ + value: `option-${i + 1}`, + label: `Option ${i + 1}`, + })); + + return ( + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; + +export const WithManyOptions: Story = { + render: () => , +}; + +const WithIconsExample = () => { + const [value, setValue] = useState(""); + + return ( + + + + + + +
+ + Public +
+
+ +
+ + Private +
+
+ +
+ + Team only +
+
+
+
+ ); +}; + +export const WithIcons: Story = { + render: () => , +}; + +const ProgrammingLanguagesExample = () => { + const [value, setValue] = useState(""); + const languages = [ + "JavaScript", "TypeScript", "Python", "Java", "C++", "C#", "Ruby", + "Go", "Rust", "Swift", "Kotlin", "Scala", "PHP", "Perl", "R", + "MATLAB", "Julia", "Dart", "Lua", "Haskell", "Clojure", "Elixir", + "F#", "OCaml", "Erlang", "Nim", "Crystal", "Zig", "V", "Racket" + ]; + + return ( + + + + + + {languages.map((lang) => ( + + {lang} + + ))} + + + ); +}; + +export const ProgrammingLanguages: Story = { + render: () => , +}; + +const DisabledExample = () => { + return ( + + + + + + Disabled Option + + + ); +}; + +export const Disabled: Story = { + render: () => , +}; + +const RequiredExample = () => { + const [value, setValue] = useState(""); + + return ( +
{ e.preventDefault(); alert(`Selected: ${value}`); }}> +
+ + + + + + Option 1 + Option 2 + Option 3 + + + +
+
+ ); +}; + +export const Required: Story = { + render: () => , +}; + +const EmptyStateExample = () => { + const [value, setValue] = useState(""); + + return ( + + + + + + {/* Intentionally empty to show empty state */} + + + ); +}; + +export const EmptyState: Story = { + render: () => , +}; + +const GitBranchesExample = () => { + const [value, setValue] = useState("main"); + const branches = [ + { name: "main", isDefault: true }, + { name: "develop", isDefault: false }, + { name: "feature/user-authentication", isDefault: false }, + { name: "feature/payment-integration", isDefault: false }, + { name: "bugfix/header-alignment", isDefault: false }, + { name: "hotfix/security-patch", isDefault: false }, + { name: "release/v2.0.0", isDefault: false }, + { name: "chore/update-dependencies", isDefault: false }, + ]; + + return ( + + + + + + {branches.map((branch) => ( + +
+ + {branch.name} + {branch.isDefault && ( + default + )} +
+
+ ))} +
+
+ ); +}; + +export const GitBranches: Story = { + render: () => , +}; diff --git a/site/src/components/SearchableSelect/SearchableSelect.tsx b/site/src/components/SearchableSelect/SearchableSelect.tsx new file mode 100644 index 0000000000000..4d7ec95013d09 --- /dev/null +++ b/site/src/components/SearchableSelect/SearchableSelect.tsx @@ -0,0 +1,263 @@ +import * as React from "react"; +import { useState, useRef, useEffect } from "react"; +import { Check, ChevronDown, Search } from "lucide-react"; +import { cn } from "utils/cn"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "components/Command/Command"; + +interface SearchableSelectProps { + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + required?: boolean; + id?: string; + children?: React.ReactNode; + className?: string; + emptyMessage?: string; +} + +interface SearchableSelectTriggerProps { + id?: string; + children?: React.ReactNode; + className?: string; +} + +interface SearchableSelectContentProps { + children?: React.ReactNode; + className?: string; +} + +interface SearchableSelectItemProps { + value: string; + children?: React.ReactNode; + className?: string; +} + +interface SearchableSelectValueProps { + placeholder?: string; + className?: string; +} + +// Context to share state between compound components +interface SearchableSelectContextValue { + value?: string; + onValueChange?: (value: string) => void; + open: boolean; + setOpen: (open: boolean) => void; + disabled?: boolean; + placeholder?: string; + items: Map; + setSearch: (search: string) => void; + search: string; + emptyMessage?: string; +} + +const SearchableSelectContext = React.createContext(undefined); + +const useSearchableSelectContext = () => { + const context = React.useContext(SearchableSelectContext); + if (!context) { + throw new Error("SearchableSelect components must be used within SearchableSelect"); + } + return context; +}; + +export const SearchableSelect: React.FC = ({ + value, + onValueChange, + placeholder = "Select option", + disabled = false, + required = false, + id, + children, + className, + emptyMessage = "No results found", +}) => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const items = useRef(new Map()).current; + + // Clear search when closing + useEffect(() => { + if (!open) { + setSearch(""); + } + }, [open]); + + const contextValue: SearchableSelectContextValue = { + value, + onValueChange, + open, + setOpen, + disabled, + placeholder, + items, + setSearch, + search, + emptyMessage, + }; + + return ( + +
+ {children} +
+
+ ); +}; + +export const SearchableSelectTrigger = React.forwardRef< + HTMLButtonElement, + SearchableSelectTriggerProps +>(({ id, children, className }, ref) => { + const { open, setOpen, disabled } = useSearchableSelectContext(); + + return ( + + + + + + ); +}); +SearchableSelectTrigger.displayName = "SearchableSelectTrigger"; + +export const SearchableSelectValue: React.FC = ({ + placeholder, + className, +}) => { + const { value, items, placeholder: contextPlaceholder } = useSearchableSelectContext(); + const displayPlaceholder = placeholder || contextPlaceholder; + + return ( + <> + + {value ? items.get(value) || value : displayPlaceholder} + + + + ); +}; + +export const SearchableSelectContent: React.FC = ({ + children, + className, +}) => { + const { setSearch, search, emptyMessage } = useSearchableSelectContext(); + + return ( + + +
+ + +
+ + + {emptyMessage} + + + {children} + + +
+
+ ); +}; + +export const SearchableSelectItem: React.FC = ({ + value, + children, + className, +}) => { + const { value: selectedValue, onValueChange, setOpen, items, search } = useSearchableSelectContext(); + + // Register item content + useEffect(() => { + items.set(value, children); + return () => { + items.delete(value); + }; + }, [value, children, items]); + + // Simple search filter + const searchableText = React.Children.toArray(children) + .map(child => { + if (typeof child === 'string') return child; + if (React.isValidElement(child) && typeof child.props.children === 'string') { + return child.props.children; + } + return ''; + }) + .join(' ') + .toLowerCase(); + + const isVisible = !search || searchableText.includes(search.toLowerCase()) || value.toLowerCase().includes(search.toLowerCase()); + + if (!isVisible) { + return null; + } + + return ( + { + onValueChange?.(value); + setOpen(false); + }} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5", + "pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary", + "focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + > + {children} + {selectedValue === value && ( + + + + )} + + ); +}; diff --git a/site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx b/site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx new file mode 100644 index 0000000000000..7a838faebbe5f --- /dev/null +++ b/site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx @@ -0,0 +1,306 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + SearchableSelect, + SearchableSelectContent, + SearchableSelectItem, + SearchableSelectTrigger, + SearchableSelectValue, +} from "../SearchableSelect"; + +describe("SearchableSelect", () => { + it("renders with placeholder", () => { + render( + + + + + + Option 1 + + + ); + + expect(screen.getByText("Select an option")).toBeInTheDocument(); + }); + + it("opens dropdown when trigger is clicked", async () => { + const user = userEvent.setup(); + + render( + + + + + + Option 1 + Option 2 + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + }); + + it("filters options based on search input", async () => { + const user = userEvent.setup(); + + render( + + + + + + Apple + Banana + Cherry + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const searchInput = screen.getByPlaceholderText("Search..."); + await user.type(searchInput, "ban"); + + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.queryByText("Apple")).not.toBeInTheDocument(); + expect(screen.queryByText("Cherry")).not.toBeInTheDocument(); + }); + + it("selects an option when clicked", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + render( + + + + + + Option 1 + Option 2 + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const option2 = screen.getByText("Option 2"); + await user.click(option2); + + expect(onValueChange).toHaveBeenCalledWith("option2"); + + // Dropdown should close after selection + await waitFor(() => { + expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument(); + }); + }); + + it("displays selected value", () => { + render( + + + + + + Option 1 + Option 2 + + + ); + + expect(screen.getByRole("combobox")).toHaveTextContent("Option 2"); + }); + + it("shows check mark for selected option", async () => { + const user = userEvent.setup(); + + render( + + + + + + Option 1 + Option 2 + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + // The selected item should have a check mark (SVG element) + const option2Item = screen.getByText("Option 2").closest('[role="option"]'); + const checkIcon = option2Item?.querySelector('svg'); + expect(checkIcon).toBeInTheDocument(); + }); + + it("shows empty message when no results match search", async () => { + const user = userEvent.setup(); + + render( + + + + + + Apple + Banana + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const searchInput = screen.getByPlaceholderText("Search..."); + await user.type(searchInput, "xyz"); + + expect(screen.getByText("No items found")).toBeInTheDocument(); + }); + + it("clears search when dropdown closes", async () => { + const user = userEvent.setup(); + + render( + + + + + + Apple + Banana + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const searchInput = screen.getByPlaceholderText("Search..."); + await user.type(searchInput, "ban"); + + // Close by clicking outside + await user.click(document.body); + + // Reopen + await user.click(trigger); + + // All options should be visible again + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + }); + + it("respects disabled state", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + render( + + + + + + Option 1 + + + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toBeDisabled(); + + await user.click(trigger); + expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument(); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("supports custom id", () => { + render( + + + + + + Option 1 + + + ); + + expect(document.getElementById("my-select")).toBeInTheDocument(); + expect(document.getElementById("my-trigger")).toBeInTheDocument(); + }); + + it("filters by option value when text doesn't match", async () => { + const user = userEvent.setup(); + + render( + + + + + + US East (N. Virginia) + EU (Ireland) + Asia Pacific (Singapore) + + + ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const searchInput = screen.getByPlaceholderText("Search..."); + await user.type(searchInput, "west"); + + // Should find the EU option by its value + expect(screen.getByText("EU (Ireland)")).toBeInTheDocument(); + expect(screen.queryByText("US East (N. Virginia)")).not.toBeInTheDocument(); + expect(screen.queryByText("Asia Pacific (Singapore)")).not.toBeInTheDocument(); + }); + + it("supports complex content in items", async () => { + const user = userEvent.setup(); + + render( + + + + + + +
+ 🍎 + Apple +
+
+ +
+ 🍌 + Banana +
+
+
+
+ ); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + const searchInput = screen.getByPlaceholderText("Search..."); + await user.type(searchInput, "apple"); + + // Should still find Apple even with complex structure + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.queryByText("Banana")).not.toBeInTheDocument(); + }); +}); diff --git a/site/src/components/SearchableSelect/index.ts b/site/src/components/SearchableSelect/index.ts new file mode 100644 index 0000000000000..92ec6763c1aa3 --- /dev/null +++ b/site/src/components/SearchableSelect/index.ts @@ -0,0 +1,7 @@ +export { + SearchableSelect, + SearchableSelectContent, + SearchableSelectItem, + SearchableSelectTrigger, + SearchableSelectValue, +} from "./SearchableSelect"; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index 6eb5f2f77d2a2..1a091f059f6ba 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -10,7 +10,7 @@ const meta: Meta = { }, args: { parameter: MockPreviewParameter, - onChange: () => {}, + onChange: () => { }, }, }; @@ -78,6 +78,41 @@ export const Dropdown: Story = { }, }; +export const DropdownWithManyOptions: Story = { + args: { + parameter: { + ...MockPreviewParameter, + form_type: "dropdown", + type: "string", + options: [ + { name: "JavaScript", value: { valid: true, value: "javascript" }, description: "JavaScript programming language", icon: "" }, + { name: "TypeScript", value: { valid: true, value: "typescript" }, description: "TypeScript programming language", icon: "" }, + { name: "Python", value: { valid: true, value: "python" }, description: "Python programming language", icon: "" }, + { name: "Java", value: { valid: true, value: "java" }, description: "Java programming language", icon: "" }, + { name: "C++", value: { valid: true, value: "cpp" }, description: "C++ programming language", icon: "" }, + { name: "C#", value: { valid: true, value: "csharp" }, description: "C# programming language", icon: "" }, + { name: "Ruby", value: { valid: true, value: "ruby" }, description: "Ruby programming language", icon: "" }, + { name: "Go", value: { valid: true, value: "go" }, description: "Go programming language", icon: "" }, + { name: "Rust", value: { valid: true, value: "rust" }, description: "Rust programming language", icon: "" }, + { name: "Swift", value: { valid: true, value: "swift" }, description: "Swift programming language", icon: "" }, + { name: "Kotlin", value: { valid: true, value: "kotlin" }, description: "Kotlin programming language", icon: "" }, + { name: "Scala", value: { valid: true, value: "scala" }, description: "Scala programming language", icon: "" }, + { name: "PHP", value: { valid: true, value: "php" }, description: "PHP programming language", icon: "" }, + { name: "Perl", value: { valid: true, value: "perl" }, description: "Perl programming language", icon: "" }, + { name: "R", value: { valid: true, value: "r" }, description: "R programming language", icon: "" }, + { name: "MATLAB", value: { valid: true, value: "matlab" }, description: "MATLAB programming language", icon: "" }, + { name: "Julia", value: { valid: true, value: "julia" }, description: "Julia programming language", icon: "" }, + { name: "Dart", value: { valid: true, value: "dart" }, description: "Dart programming language", icon: "" }, + { name: "Lua", value: { valid: true, value: "lua" }, description: "Lua programming language", icon: "" }, + { name: "Haskell", value: { valid: true, value: "haskell" }, description: "Haskell programming language", icon: "" }, + ], + styling: { + placeholder: "Select a programming language", + }, + }, + }, +}; + export const MultiSelect: Story = { args: { parameter: { diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 5665129eadba3..a5ab2f19f2cea 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -23,6 +23,13 @@ import { SelectTrigger, SelectValue, } from "components/Select/Select"; +import { + SearchableSelect, + SearchableSelectContent, + SearchableSelectItem, + SearchableSelectTrigger, + SearchableSelectValue, +} from "components/SearchableSelect"; import { Slider } from "components/Slider/Slider"; import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; @@ -83,7 +90,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -441,36 +448,35 @@ const ParameterField: FC = ({ }; return ( - + + ); } @@ -691,11 +697,10 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From b9037c2705510b339428dc5a9ac60ff6c2cf59c1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 3 Jul 2025 22:48:26 +0000 Subject: [PATCH 02/21] .. --- .../SearchableSelect.stories.tsx | 68 +++++++-- .../{__tests__ => }/SearchableSelect.test.tsx | 46 +++--- .../SearchableSelect/SearchableSelect.tsx | 75 +++++---- site/src/components/SearchableSelect/index.ts | 7 - .../DynamicParameter.stories.tsx | 142 +++++++++++++++--- .../DynamicParameter/DynamicParameter.tsx | 22 +-- 6 files changed, 259 insertions(+), 101 deletions(-) rename site/src/components/SearchableSelect/{__tests__ => }/SearchableSelect.test.tsx (91%) delete mode 100644 site/src/components/SearchableSelect/index.ts diff --git a/site/src/components/SearchableSelect/SearchableSelect.stories.tsx b/site/src/components/SearchableSelect/SearchableSelect.stories.tsx index f6e79e9a77408..a15e4d97a64ea 100644 --- a/site/src/components/SearchableSelect/SearchableSelect.stories.tsx +++ b/site/src/components/SearchableSelect/SearchableSelect.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { GitBranch, Globe, Lock, Users } from "lucide-react"; import { useState } from "react"; import { SearchableSelect, @@ -7,7 +8,6 @@ import { SearchableSelectTrigger, SearchableSelectValue, } from "./SearchableSelect"; -import { GitBranch, Globe, Lock, Users } from "lucide-react"; const meta: Meta = { title: "components/SearchableSelect", @@ -112,10 +112,36 @@ export const WithIcons: Story = { const ProgrammingLanguagesExample = () => { const [value, setValue] = useState(""); const languages = [ - "JavaScript", "TypeScript", "Python", "Java", "C++", "C#", "Ruby", - "Go", "Rust", "Swift", "Kotlin", "Scala", "PHP", "Perl", "R", - "MATLAB", "Julia", "Dart", "Lua", "Haskell", "Clojure", "Elixir", - "F#", "OCaml", "Erlang", "Nim", "Crystal", "Zig", "V", "Racket" + "JavaScript", + "TypeScript", + "Python", + "Java", + "C++", + "C#", + "Ruby", + "Go", + "Rust", + "Swift", + "Kotlin", + "Scala", + "PHP", + "Perl", + "R", + "MATLAB", + "Julia", + "Dart", + "Lua", + "Haskell", + "Clojure", + "Elixir", + "F#", + "OCaml", + "Erlang", + "Nim", + "Crystal", + "Zig", + "V", + "Racket", ]; return ( @@ -149,7 +175,9 @@ const DisabledExample = () => { - Disabled Option + + Disabled Option + ); @@ -163,7 +191,12 @@ const RequiredExample = () => { const [value, setValue] = useState(""); return ( -
{ e.preventDefault(); alert(`Selected: ${value}`); }}> + { + e.preventDefault(); + alert(`Selected: ${value}`); + }} + >
{ - Option 1 - Option 2 - Option 3 + + Option 1 + + + Option 2 + + + Option 3 + -
@@ -244,7 +286,9 @@ const GitBranchesExample = () => { {branch.name} {branch.isDefault && ( - default + + default + )}
diff --git a/site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx b/site/src/components/SearchableSelect/SearchableSelect.test.tsx similarity index 91% rename from site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx rename to site/src/components/SearchableSelect/SearchableSelect.test.tsx index 7a838faebbe5f..ea691a07ad939 100644 --- a/site/src/components/SearchableSelect/__tests__/SearchableSelect.test.tsx +++ b/site/src/components/SearchableSelect/SearchableSelect.test.tsx @@ -19,7 +19,7 @@ describe("SearchableSelect", () => { Option 1 - + , ); expect(screen.getByText("Select an option")).toBeInTheDocument(); @@ -37,7 +37,7 @@ describe("SearchableSelect", () => { Option 1 Option 2 - + , ); const trigger = screen.getByRole("combobox"); @@ -61,7 +61,7 @@ describe("SearchableSelect", () => { Banana Cherry - + , ); const trigger = screen.getByRole("combobox"); @@ -88,7 +88,7 @@ describe("SearchableSelect", () => { Option 1 Option 2 - + , ); const trigger = screen.getByRole("combobox"); @@ -101,7 +101,9 @@ describe("SearchableSelect", () => { // Dropdown should close after selection await waitFor(() => { - expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText("Search..."), + ).not.toBeInTheDocument(); }); }); @@ -115,7 +117,7 @@ describe("SearchableSelect", () => { Option 1 Option 2 - + , ); expect(screen.getByRole("combobox")).toHaveTextContent("Option 2"); @@ -133,7 +135,7 @@ describe("SearchableSelect", () => { Option 1 Option 2 - + , ); const trigger = screen.getByRole("combobox"); @@ -141,7 +143,7 @@ describe("SearchableSelect", () => { // The selected item should have a check mark (SVG element) const option2Item = screen.getByText("Option 2").closest('[role="option"]'); - const checkIcon = option2Item?.querySelector('svg'); + const checkIcon = option2Item?.querySelector("svg"); expect(checkIcon).toBeInTheDocument(); }); @@ -157,7 +159,7 @@ describe("SearchableSelect", () => { Apple Banana - + , ); const trigger = screen.getByRole("combobox"); @@ -181,7 +183,7 @@ describe("SearchableSelect", () => { Apple Banana - + , ); const trigger = screen.getByRole("combobox"); @@ -213,7 +215,7 @@ describe("SearchableSelect", () => { Option 1 - + , ); const trigger = screen.getByRole("combobox"); @@ -233,7 +235,7 @@ describe("SearchableSelect", () => { Option 1 - + , ); expect(document.getElementById("my-select")).toBeInTheDocument(); @@ -249,11 +251,17 @@ describe("SearchableSelect", () => { - US East (N. Virginia) - EU (Ireland) - Asia Pacific (Singapore) + + US East (N. Virginia) + + + EU (Ireland) + + + Asia Pacific (Singapore) + - + , ); const trigger = screen.getByRole("combobox"); @@ -265,7 +273,9 @@ describe("SearchableSelect", () => { // Should find the EU option by its value expect(screen.getByText("EU (Ireland)")).toBeInTheDocument(); expect(screen.queryByText("US East (N. Virginia)")).not.toBeInTheDocument(); - expect(screen.queryByText("Asia Pacific (Singapore)")).not.toBeInTheDocument(); + expect( + screen.queryByText("Asia Pacific (Singapore)"), + ).not.toBeInTheDocument(); }); it("supports complex content in items", async () => { @@ -290,7 +300,7 @@ describe("SearchableSelect", () => {
- + , ); const trigger = screen.getByRole("combobox"); diff --git a/site/src/components/SearchableSelect/SearchableSelect.tsx b/site/src/components/SearchableSelect/SearchableSelect.tsx index 4d7ec95013d09..7b1c12f04005d 100644 --- a/site/src/components/SearchableSelect/SearchableSelect.tsx +++ b/site/src/components/SearchableSelect/SearchableSelect.tsx @@ -1,12 +1,3 @@ -import * as React from "react"; -import { useState, useRef, useEffect } from "react"; -import { Check, ChevronDown, Search } from "lucide-react"; -import { cn } from "utils/cn"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "components/Popover/Popover"; import { Command, CommandEmpty, @@ -15,6 +6,15 @@ import { CommandItem, CommandList, } from "components/Command/Command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import { Check, ChevronDown, Search } from "lucide-react"; +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "utils/cn"; interface SearchableSelectProps { value?: string; @@ -64,12 +64,16 @@ interface SearchableSelectContextValue { emptyMessage?: string; } -const SearchableSelectContext = React.createContext(undefined); +const SearchableSelectContext = React.createContext< + SearchableSelectContextValue | undefined +>(undefined); const useSearchableSelectContext = () => { const context = React.useContext(SearchableSelectContext); if (!context) { - throw new Error("SearchableSelect components must be used within SearchableSelect"); + throw new Error( + "SearchableSelect components must be used within SearchableSelect", + ); } return context; }; @@ -141,7 +145,7 @@ export const SearchableSelectTrigger = React.forwardRef< ring-offset-background text-content-secondary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link`, - className + className, )} > {children} @@ -156,7 +160,11 @@ export const SearchableSelectValue: React.FC = ({ placeholder, className, }) => { - const { value, items, placeholder: contextPlaceholder } = useSearchableSelectContext(); + const { + value, + items, + placeholder: contextPlaceholder, + } = useSearchableSelectContext(); const displayPlaceholder = placeholder || contextPlaceholder; return ( @@ -169,17 +177,16 @@ export const SearchableSelectValue: React.FC = ({ ); }; -export const SearchableSelectContent: React.FC = ({ - children, - className, -}) => { +export const SearchableSelectContent: React.FC< + SearchableSelectContentProps +> = ({ children, className }) => { const { setSearch, search, emptyMessage } = useSearchableSelectContext(); return ( @@ -196,9 +203,7 @@ export const SearchableSelectContent: React.FC = ( {emptyMessage} - - {children} - + {children} @@ -210,7 +215,13 @@ export const SearchableSelectItem: React.FC = ({ children, className, }) => { - const { value: selectedValue, onValueChange, setOpen, items, search } = useSearchableSelectContext(); + const { + value: selectedValue, + onValueChange, + setOpen, + items, + search, + } = useSearchableSelectContext(); // Register item content useEffect(() => { @@ -222,17 +233,23 @@ export const SearchableSelectItem: React.FC = ({ // Simple search filter const searchableText = React.Children.toArray(children) - .map(child => { - if (typeof child === 'string') return child; - if (React.isValidElement(child) && typeof child.props.children === 'string') { + .map((child) => { + if (typeof child === "string") return child; + if ( + React.isValidElement(child) && + typeof child.props.children === "string" + ) { return child.props.children; } - return ''; + return ""; }) - .join(' ') + .join(" ") .toLowerCase(); - const isVisible = !search || searchableText.includes(search.toLowerCase()) || value.toLowerCase().includes(search.toLowerCase()); + const isVisible = + !search || + searchableText.includes(search.toLowerCase()) || + value.toLowerCase().includes(search.toLowerCase()); if (!isVisible) { return null; @@ -249,7 +266,7 @@ export const SearchableSelectItem: React.FC = ({ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5", "pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary", "focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} > {children} diff --git a/site/src/components/SearchableSelect/index.ts b/site/src/components/SearchableSelect/index.ts deleted file mode 100644 index 92ec6763c1aa3..0000000000000 --- a/site/src/components/SearchableSelect/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - SearchableSelect, - SearchableSelectContent, - SearchableSelectItem, - SearchableSelectTrigger, - SearchableSelectValue, -} from "./SearchableSelect"; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index 1a091f059f6ba..e1baa28e72ed2 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -10,7 +10,7 @@ const meta: Meta = { }, args: { parameter: MockPreviewParameter, - onChange: () => { }, + onChange: () => {}, }, }; @@ -85,26 +85,126 @@ export const DropdownWithManyOptions: Story = { form_type: "dropdown", type: "string", options: [ - { name: "JavaScript", value: { valid: true, value: "javascript" }, description: "JavaScript programming language", icon: "" }, - { name: "TypeScript", value: { valid: true, value: "typescript" }, description: "TypeScript programming language", icon: "" }, - { name: "Python", value: { valid: true, value: "python" }, description: "Python programming language", icon: "" }, - { name: "Java", value: { valid: true, value: "java" }, description: "Java programming language", icon: "" }, - { name: "C++", value: { valid: true, value: "cpp" }, description: "C++ programming language", icon: "" }, - { name: "C#", value: { valid: true, value: "csharp" }, description: "C# programming language", icon: "" }, - { name: "Ruby", value: { valid: true, value: "ruby" }, description: "Ruby programming language", icon: "" }, - { name: "Go", value: { valid: true, value: "go" }, description: "Go programming language", icon: "" }, - { name: "Rust", value: { valid: true, value: "rust" }, description: "Rust programming language", icon: "" }, - { name: "Swift", value: { valid: true, value: "swift" }, description: "Swift programming language", icon: "" }, - { name: "Kotlin", value: { valid: true, value: "kotlin" }, description: "Kotlin programming language", icon: "" }, - { name: "Scala", value: { valid: true, value: "scala" }, description: "Scala programming language", icon: "" }, - { name: "PHP", value: { valid: true, value: "php" }, description: "PHP programming language", icon: "" }, - { name: "Perl", value: { valid: true, value: "perl" }, description: "Perl programming language", icon: "" }, - { name: "R", value: { valid: true, value: "r" }, description: "R programming language", icon: "" }, - { name: "MATLAB", value: { valid: true, value: "matlab" }, description: "MATLAB programming language", icon: "" }, - { name: "Julia", value: { valid: true, value: "julia" }, description: "Julia programming language", icon: "" }, - { name: "Dart", value: { valid: true, value: "dart" }, description: "Dart programming language", icon: "" }, - { name: "Lua", value: { valid: true, value: "lua" }, description: "Lua programming language", icon: "" }, - { name: "Haskell", value: { valid: true, value: "haskell" }, description: "Haskell programming language", icon: "" }, + { + name: "JavaScript", + value: { valid: true, value: "javascript" }, + description: "JavaScript programming language", + icon: "", + }, + { + name: "TypeScript", + value: { valid: true, value: "typescript" }, + description: "TypeScript programming language", + icon: "", + }, + { + name: "Python", + value: { valid: true, value: "python" }, + description: "Python programming language", + icon: "", + }, + { + name: "Java", + value: { valid: true, value: "java" }, + description: "Java programming language", + icon: "", + }, + { + name: "C++", + value: { valid: true, value: "cpp" }, + description: "C++ programming language", + icon: "", + }, + { + name: "C#", + value: { valid: true, value: "csharp" }, + description: "C# programming language", + icon: "", + }, + { + name: "Ruby", + value: { valid: true, value: "ruby" }, + description: "Ruby programming language", + icon: "", + }, + { + name: "Go", + value: { valid: true, value: "go" }, + description: "Go programming language", + icon: "", + }, + { + name: "Rust", + value: { valid: true, value: "rust" }, + description: "Rust programming language", + icon: "", + }, + { + name: "Swift", + value: { valid: true, value: "swift" }, + description: "Swift programming language", + icon: "", + }, + { + name: "Kotlin", + value: { valid: true, value: "kotlin" }, + description: "Kotlin programming language", + icon: "", + }, + { + name: "Scala", + value: { valid: true, value: "scala" }, + description: "Scala programming language", + icon: "", + }, + { + name: "PHP", + value: { valid: true, value: "php" }, + description: "PHP programming language", + icon: "", + }, + { + name: "Perl", + value: { valid: true, value: "perl" }, + description: "Perl programming language", + icon: "", + }, + { + name: "R", + value: { valid: true, value: "r" }, + description: "R programming language", + icon: "", + }, + { + name: "MATLAB", + value: { valid: true, value: "matlab" }, + description: "MATLAB programming language", + icon: "", + }, + { + name: "Julia", + value: { valid: true, value: "julia" }, + description: "Julia programming language", + icon: "", + }, + { + name: "Dart", + value: { valid: true, value: "dart" }, + description: "Dart programming language", + icon: "", + }, + { + name: "Lua", + value: { valid: true, value: "lua" }, + description: "Lua programming language", + icon: "", + }, + { + name: "Haskell", + value: { valid: true, value: "haskell" }, + description: "Haskell programming language", + icon: "", + }, ], styling: { placeholder: "Select a programming language", diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 1339f2db56543..dfdcf30200b3b 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -16,13 +16,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 { SearchableSelect, SearchableSelectContent, @@ -90,7 +83,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -698,10 +691,11 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From 37731791159d2a9b93c15d15761d28e5cf268e64 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 7 Jul 2025 16:28:00 +0000 Subject: [PATCH 03/21] something --- .../SearchableSelect.test.tsx | 7 +++---- site/src/components/Select/Select.tsx | 21 ++++++++++++++----- .../DynamicParameter/DynamicParameter.tsx | 13 ++++++------ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/site/src/components/SearchableSelect/SearchableSelect.test.tsx b/site/src/components/SearchableSelect/SearchableSelect.test.tsx index ea691a07ad939..0f75f180f90c6 100644 --- a/site/src/components/SearchableSelect/SearchableSelect.test.tsx +++ b/site/src/components/SearchableSelect/SearchableSelect.test.tsx @@ -1,13 +1,12 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; import { SearchableSelect, SearchableSelectContent, SearchableSelectItem, SearchableSelectTrigger, SearchableSelectValue, -} from "../SearchableSelect"; +} from "./SearchableSelect"; describe("SearchableSelect", () => { it("renders with placeholder", () => { @@ -77,7 +76,7 @@ describe("SearchableSelect", () => { it("selects an option when clicked", async () => { const user = userEvent.setup(); - const onValueChange = vi.fn(); + const onValueChange = jest.fn(); render( @@ -205,7 +204,7 @@ describe("SearchableSelect", () => { it("respects disabled state", async () => { const user = userEvent.setup(); - const onValueChange = vi.fn(); + const onValueChange = jest.fn(); render( diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index 3d2f8ffc3b706..1ddfa80d5f16a 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -9,15 +9,26 @@ import { cn } from "utils/cn"; export const Select = SelectPrimitive.Root; +type SearchableSelectContext = { query: string, setQuery: (next: string) => void }; +const SearchableSelectContext = React.createContext({ query: "", setQuery: () => { } }); +export const SearchableSelect: React.FC = (({ children, ...props }) => { + const [query, setQuery] = React.useState(""); + + return + + {children} + + +}); +SearchableSelect.displayName = SelectPrimitive.Root.displayName; + export const SelectGroup = SelectPrimitive.Group; export const SelectValue = SelectPrimitive.Value; export const SelectTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { - id?: string; - } + React.ComponentPropsWithoutRef >(({ className, children, id, ...props }, ref) => ( {children} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index dfdcf30200b3b..e846b9fb1c16e 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -22,7 +22,7 @@ import { SearchableSelectItem, SearchableSelectTrigger, SearchableSelectValue, -} from "components/SearchableSelect"; +} from "components/SearchableSelect/SearchableSelect"; import { Slider } from "components/Slider/Slider"; import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; @@ -83,7 +83,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -691,11 +691,10 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From e0cdebf6ed625d77664a402283459d583547525c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 7 Jul 2025 17:47:08 +0000 Subject: [PATCH 04/21] ugugghghghugh --- site/src/components/Command/Command.tsx | 21 - site/src/components/Select/Select.tsx | 12 +- .../SelectCombobox/SelectCombobox.stories.tsx | 163 +++++ .../SelectCombobox/SelectCombobox.tsx | 680 ++++++++++++++++++ .../DynamicParameter/DynamicParameter.tsx | 11 +- site/src/testHelpers/entities.ts | 379 ++++------ site/src/theme/mui.ts | 23 +- site/src/theme/roles.ts | 3 - 8 files changed, 1011 insertions(+), 281 deletions(-) create mode 100644 site/src/components/SelectCombobox/SelectCombobox.stories.tsx create mode 100644 site/src/components/SelectCombobox/SelectCombobox.tsx diff --git a/site/src/components/Command/Command.tsx b/site/src/components/Command/Command.tsx index 88451d13b72ee..54695cdc9f720 100644 --- a/site/src/components/Command/Command.tsx +++ b/site/src/components/Command/Command.tsx @@ -23,27 +23,6 @@ export const Command = forwardRef< /> )); -const CommandDialog: FC = ({ children, ...props }) => { - return ( - - - - {children} - - - - ); -}; - export const CommandInput = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index 1ddfa80d5f16a..be9695e8d354b 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -94,14 +94,14 @@ export const SelectContent = React.forwardRef< = { + title: "components/SelectCombobox", + component: SelectCombobox, + args: { + hidePlaceholderWhenSelected: true, + placeholder: "Select organization", + emptyIndicator: ( +

+ All organizations selected +

+ ), + options: organizations.map((org) => ({ + label: org.display_name, + value: org.id, + })), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const OpenCombobox: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + + await waitFor(() => + expect(canvas.getByText("My Organization")).toBeInTheDocument(), + ); + }, +}; + +export const WithIcons: Story = { + args: { + options: organizations.map((org) => ({ + label: org.display_name, + value: org.id, + icon: org.icon, + })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await waitFor(() => + expect(canvas.getByText("My Organization")).toBeInTheDocument(), + ); + }, +}; + +export const SelectComboboxItem: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + }, +}; + +export const SelectAllComboboxItems: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization 2" }), + ); + + await waitFor(() => + expect( + canvas.getByText("All organizations selected"), + ).toBeInTheDocument(), + ); + }, +}; + +export const ClearFirstSelectedItem: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization 2" }), + ); + await userEvent.click(canvas.getAllByTestId("clear-option-button")[0]); + }, +}; + +export const ClearAllComboboxItems: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click(canvas.getByTestId("clear-all-button")); + + await waitFor(() => + expect( + canvas.getByPlaceholderText("Select organization"), + ).toBeInTheDocument(), + ); + }, +}; + +export const WithGroups: Story = { + args: { + placeholder: "Make a playlist", + groupBy: "album", + options: [ + { + label: "Photo Facing Water", + value: "photo-facing-water", + album: "Papillon", + icon: "/emojis/1f301.png", + }, + { + label: "Mercurial", + value: "mercurial", + album: "Papillon", + icon: "/emojis/1fa90.png", + }, + { + label: "Merging", + value: "merging", + album: "Papillon", + icon: "/lol-not-a-real-image.png", + }, + { + label: "Flacks", + value: "flacks", + album: "aBliss", + // intentionally omitted icon + }, + { + label: "aBliss", + value: "abliss", + album: "aBliss", + // intentionally omitted icon + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Make a playlist")); + await waitFor(() => + expect(canvas.getByText("Papillon")).toBeInTheDocument(), + ); + }, +}; diff --git a/site/src/components/SelectCombobox/SelectCombobox.tsx b/site/src/components/SelectCombobox/SelectCombobox.tsx new file mode 100644 index 0000000000000..d5fd5fec15e6f --- /dev/null +++ b/site/src/components/SelectCombobox/SelectCombobox.tsx @@ -0,0 +1,680 @@ +/** + * This component is based on multiple-selector + * @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector} + */ +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { Avatar } from "components/Avatar/Avatar"; +import { Badge } from "components/Badge/Badge"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "components/Command/Command"; +import { useDebouncedValue } from "hooks/debounce"; +import { ChevronDown, X } from "lucide-react"; +import { + type ComponentProps, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type ReactNode, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "utils/cn"; + +export interface Option { + value: string; + label: string; + icon?: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface SelectComboboxProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: ReactNode; + /** Empty component. */ + emptyIndicator?: ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => Option[]; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @see {@link https://github.com/pacocoursey/cmdk/issues/171} + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; + /** hide or show the button that clears all the selected options. */ + hideClearAllButton?: boolean; +} + +interface SelectComboboxRef { + selectedValue: Option[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +function transitionToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + for (const option of options) { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + } + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = structuredClone(groupOption); + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter( + (val) => !picked.find((p) => p.value === val.value), + ); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + return Object.values(groupOption).some((value) => + value.some((option) => targetOption.some((o) => o.value === option.value)), + ); +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk-empty to not render correctly. + * Here a new CommandEmpty is created using the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +export const SelectCombobox = forwardRef< + SelectComboboxRef, + SelectComboboxProps +>( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: SelectComboboxProps, + ref, + ) => { + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [onScrollbar, setOnScrollbar] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const dropdownRef = useRef(null); + + const [selected, setSelected] = useState( + arrayDefaultOptions ?? [], + ); + const [options, setOptions] = useState( + transitionToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = useState(""); + const debouncedSearchTerm = useDebouncedValue(inputValue, delay || 500); + + const [previousValue, setPreviousValue] = useState(value || []); + if (value && value !== previousValue) { + setPreviousValue(value); + setSelected(value); + } + + useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]), + }), + [selected], + ); + + const handleUnselect = useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }; + + useEffect(() => { + if (!open) { + return; + } + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchend", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + }; + }, [open]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transitionToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transitionToGroupOption(res || [], groupBy)); + }; + + const exec = () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + }, [ + debouncedSearchTerm, + groupBy, + open, + triggerSearchOnFocus, + onSearchSync, + ]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transitionToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]); + + const CreatableItem = () => { + if (!creatable) { + return undefined; + } + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + Create "{inputValue}" + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = () => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }; + + if (inputRef.current && inputProps?.id) { + inputRef.current.id = inputProps?.id; + } + + const fixedOptions = selected.filter((s) => s.fixed); + const showIcons = arrayOptions?.some((it) => it.icon); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn( + "h-auto overflow-visible bg-transparent", + commandProps?.className, + )} + shouldFilter={ + commandProps?.shouldFilter !== undefined + ? commandProps.shouldFilter + : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */} +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+
+ {selected.map((option) => { + return ( + +
+ {option.icon && ( + + )} + {option.label} +
+ +
+ ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={ + hidePlaceholderWhenSelected && selected.length !== 0 + ? "" + : placeholder + } + className={cn( + "flex-1 border-none outline-none bg-transparent placeholder:text-content-secondary", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2.5": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> +
+
+ + +
+
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onPointerEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + {/* biome-ignore lint/complexity/noUselessFragments: A parent element is + needed for multiple dropdown items */} + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && + "cursor-default text-content-disabled", + )} + > +
+ {showIcons && ( + + )} + {option.label} +
+
+ ); + })} + +
+ ))} + + )} +
+ )} +
+
+ ); + }, +); diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index e846b9fb1c16e..3052bfdb14b42 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -83,7 +83,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -691,10 +691,11 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 22dc47ae2390f..a16b10be5336e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -550,18 +550,18 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { }; export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = - { - organization_id: MockOrganization.id, - user_id: MockUserMember.id, - username: MockUserMember.username, - email: MockUserMember.email, - updated_at: "2025-05-22T17:51:49.49745Z", - created_at: "2025-05-22T17:51:49.497449Z", - name: MockUserMember.name, - avatar_url: MockUserMember.avatar_url, - global_roles: MockUserMember.roles, - roles: [], - }; +{ + organization_id: MockOrganization.id, + user_id: MockUserMember.id, + username: MockUserMember.username, + email: MockUserMember.email, + updated_at: "2025-05-22T17:51:49.49745Z", + created_at: "2025-05-22T17:51:49.497449Z", + name: MockUserMember.name, + avatar_url: MockUserMember.avatar_url, + global_roles: MockUserMember.roles, + roles: [], +}; export const MockProvisionerKey: TypesGen.ProvisionerKey = { id: "test-provisioner-key", @@ -753,11 +753,11 @@ You can add instructions here }; export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = - { - ...MockTemplateVersion, - id: "test-template-version-markdown", - name: "test-version-markdown", - message: ` +{ + ...MockTemplateVersion, + id: "test-template-version-markdown", + name: "test-version-markdown", + message: ` # Abiding Grace ## Enchantment At the beginning of your end step, choose one — @@ -766,7 +766,7 @@ At the beginning of your end step, choose one — - Return target creature card with mana value 1 from your graveyard to the battlefield. `, - }; +}; export const MockTemplate: TypesGen.Template = { id: "test-template", @@ -1243,16 +1243,16 @@ export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { }; const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - schedule: "", - }; +{ + schedule: "", +}; const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - // Runs at 9:30am Monday through Friday using Canada/Eastern - // (America/Toronto) time - schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", - }; +{ + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", +}; export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { build_number: 1, @@ -1536,13 +1536,13 @@ const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { }; export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: "stopped", - }, - }; +{ + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, +}; const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockOutdatedRunningWorkspaceAlwaysUpdate, @@ -1573,75 +1573,71 @@ export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { count: 26, }; -const MockWorkspacesResponseWithDeletions = { - workspaces: [...MockWorkspacesResponse.workspaces, MockWorkspaceWithDeletion], - count: MockWorkspacesResponse.count + 1, -}; export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = - { - name: "first_parameter", - type: "string", - form_type: "input", - description: "This is first parameter", - description_plaintext: "Markdown: This is first parameter", - default_value: "abc", - mutable: true, - icon: "/icon/folder.svg", - options: [], - required: true, - ephemeral: false, - }; +{ + name: "first_parameter", + type: "string", + form_type: "input", + description: "This is first parameter", + description_plaintext: "Markdown: This is first parameter", + default_value: "abc", + mutable: true, + icon: "/icon/folder.svg", + options: [], + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = - { - name: "second_parameter", - type: "number", - form_type: "input", - description: "This is second parameter", - description_plaintext: "Markdown: This is second parameter", - default_value: "2", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 3, - validation_monotonic: "increasing", - required: true, - ephemeral: false, - }; +{ + name: "second_parameter", + type: "number", + form_type: "input", + description: "This is second parameter", + description_plaintext: "Markdown: This is second parameter", + default_value: "2", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 3, + validation_monotonic: "increasing", + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = - { - name: "third_parameter", - type: "string", - form_type: "input", - description: "This is third parameter", - description_plaintext: "Markdown: This is third parameter", - default_value: "aaa", - mutable: true, - icon: "/icon/database.svg", - options: [], - validation_error: "No way!", - validation_regex: "^[a-z]{3}$", - required: true, - ephemeral: false, - }; +{ + name: "third_parameter", + type: "string", + form_type: "input", + description: "This is third parameter", + description_plaintext: "Markdown: This is third parameter", + default_value: "aaa", + mutable: true, + icon: "/icon/database.svg", + options: [], + validation_error: "No way!", + validation_regex: "^[a-z]{3}$", + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = - { - name: "fourth_parameter", - type: "string", - form_type: "input", - description: "This is fourth parameter", - description_plaintext: "Markdown: This is fourth parameter", - default_value: "def", - mutable: false, - icon: "/icon/database.svg", - options: [], - required: true, - ephemeral: false, - }; +{ + name: "fourth_parameter", + type: "string", + form_type: "input", + description: "This is fourth parameter", + description_plaintext: "Markdown: This is fourth parameter", + default_value: "def", + mutable: false, + icon: "/icon/database.svg", + options: [], + required: true, + ephemeral: false, +}; const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = { name: "fifth_parameter", @@ -1717,16 +1713,16 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { }; export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = - { - name: "test", - template_version_id: "test-template-version", - rich_parameter_values: [ - { - name: MockTemplateVersionParameter1.name, - value: MockTemplateVersionParameter1.default_value, - }, - ], - }; +{ + name: "test", + template_version_id: "test-template-version", + rich_parameter_values: [ + { + name: MockTemplateVersionParameter1.name, + value: MockTemplateVersionParameter1.default_value, + }, + ], +}; const MockUserAgent = { browser: "Chrome 99.0.4844", @@ -2410,30 +2406,6 @@ export const MockEntitlements: TypesGen.Entitlements = { refreshed_at: "2022-05-20T16:45:57.122Z", }; -const MockEntitlementsWithWarnings: TypesGen.Entitlements = { - errors: [], - warnings: ["You are over your active user limit.", "And another thing."], - has_license: true, - trial: false, - require_telemetry: false, - refreshed_at: "2022-05-20T16:45:57.122Z", - features: withDefaultFeatures({ - user_limit: { - enabled: true, - entitlement: "grace_period", - limit: 100, - actual: 102, - }, - audit_log: { - enabled: true, - entitlement: "entitled", - }, - browser_only: { - enabled: true, - entitlement: "entitled", - }, - }), -}; export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { errors: [], @@ -2465,21 +2437,6 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { }), }; -const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { - errors: [], - warnings: [], - has_license: true, - require_telemetry: false, - trial: false, - refreshed_at: "2022-05-20T16:45:57.122Z", - features: withDefaultFeatures({ - user_limit: { - enabled: true, - entitlement: "entitled", - limit: 25, - }, - }), -}; export const MockEntitlementsWithMultiOrg: TypesGen.Entitlements = { ...MockEntitlements, @@ -2642,42 +2599,6 @@ export const MockAuditLogGitSSH: TypesGen.AuditLog = { }, }; -const MockAuditOauthConvert: TypesGen.AuditLog = { - ...MockAuditLog, - resource_type: "convert_login", - resource_target: "oidc", - action: "create", - status_code: 201, - description: "{user} created login type conversion to {target}}", - diff: { - created_at: { - old: "0001-01-01T00:00:00Z", - new: "2023-06-20T20:44:54.243019Z", - secret: false, - }, - expires_at: { - old: "0001-01-01T00:00:00Z", - new: "2023-06-20T20:49:54.243019Z", - secret: false, - }, - state_string: { - old: "", - new: "", - secret: true, - }, - to_type: { - old: "", - new: "oidc", - secret: false, - }, - user_id: { - old: "", - new: "dc790496-eaec-4f88-a53f-8ce1f61a1fff", - secret: false, - }, - }, -}; - export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = { ...MockAuditLog, resource_type: "api_key", @@ -2779,21 +2700,21 @@ export const MockOrganizationSyncSettings: TypesGen.OrganizationSyncSettings = { }; export const MockOrganizationSyncSettings2: TypesGen.OrganizationSyncSettings = - { - field: "organization-test", - mapping: { - "idp-org-1": ["my-organization-id", "my-organization-2-id"], - "idp-org-2": ["my-organization-id"], - }, - organization_assign_default: true, - }; +{ + field: "organization-test", + mapping: { + "idp-org-1": ["my-organization-id", "my-organization-2-id"], + "idp-org-2": ["my-organization-id"], + }, + organization_assign_default: true, +}; export const MockOrganizationSyncSettingsEmpty: TypesGen.OrganizationSyncSettings = - { - field: "", - mapping: {}, - organization_assign_default: true, - }; +{ + field: "", + mapping: {}, + organization_assign_default: true, +}; export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", @@ -3026,24 +2947,24 @@ export const MockPreviewParameter: TypesGen.PreviewParameter = { }; export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = - { - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: false, - display_icon: "/icon/github.svg", - display_name: "GitHub", - }; +{ + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", +}; export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = - { - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: true, - display_icon: "/icon/github.svg", - display_name: "GitHub", - }; +{ + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: true, + display_icon: "/icon/github.svg", + display_name: "GitHub", +}; export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", @@ -3964,13 +3885,13 @@ export const MockHealth: TypesGen.HealthcheckReport = { }; export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = - { - ports: [ - { process_name: "webb", network: "", port: 30000 }, - { process_name: "gogo", network: "", port: 8080 }, - { process_name: "", network: "", port: 8081 }, - ], - }; +{ + ports: [ + { process_name: "webb", network: "", port: 30000 }, + { process_name: "gogo", network: "", port: 8080 }, + { process_name: "", network: "", port: 8081 }, + ], +}; export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { shares: [ @@ -4379,20 +4300,20 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = { }; export const MockWorkspaceAgentDevcontainer: TypesGen.WorkspaceAgentDevcontainer = - { - id: "test-devcontainer-id", - name: "test-devcontainer", - workspace_folder: "/workspace/test", - config_path: "/workspace/test/.devcontainer/devcontainer.json", - status: "running", - dirty: false, - container: MockWorkspaceAgentContainer, - agent: { - id: MockWorkspaceSubAgent.id, - name: MockWorkspaceSubAgent.name, - directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test", - }, - }; +{ + id: "test-devcontainer-id", + name: "test-devcontainer", + workspace_folder: "/workspace/test", + config_path: "/workspace/test/.devcontainer/devcontainer.json", + status: "running", + dirty: false, + container: MockWorkspaceAgentContainer, + agent: { + id: MockWorkspaceSubAgent.id, + name: MockWorkspaceSubAgent.name, + directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test", + }, +}; export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [ { diff --git a/site/src/theme/mui.ts b/site/src/theme/mui.ts index 346ca90bcd04c..5208ba0090c92 100644 --- a/site/src/theme/mui.ts +++ b/site/src/theme/mui.ts @@ -12,17 +12,6 @@ import { } from "./constants"; import tw from "./tailwindColors"; -type PaletteIndex = - | "primary" - | "secondary" - | "background" - | "text" - | "error" - | "warning" - | "info" - | "success" - | "action" - | "neutral"; // biome-ignore lint/suspicious/noExplicitAny: needed for MUI overrides type MuiStyle = any; @@ -224,9 +213,9 @@ export const components = { // This targets the first+last td elements, and also the first+last elements // of a TableCellLink. "&:not(:only-child):first-of-type, &:not(:only-child):first-of-type > a": - { - paddingLeft: 32, - }, + { + paddingLeft: 32, + }, "&:not(:only-child):last-child, &:not(:only-child):last-child > a": { paddingRight: 32, }, @@ -364,9 +353,9 @@ export const components = { }, // The default outlined input color is white, which seemed jarring. "&:hover:not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": - { - borderColor: tw.zinc[500], - }, + { + borderColor: tw.zinc[500], + }, }, }, }, diff --git a/site/src/theme/roles.ts b/site/src/theme/roles.ts index b83bd6ad15f09..f8f4866df3bbc 100644 --- a/site/src/theme/roles.ts +++ b/site/src/theme/roles.ts @@ -1,8 +1,5 @@ export type ThemeRole = keyof Roles; -type InteractiveThemeRole = keyof { - [K in keyof Roles as Roles[K] extends InteractiveRole ? K : never]: unknown; -}; export interface Roles { /** Something is wrong; either unexpectedly, or in a meaningful way. */ From 06e0a7b99076cea56c911f58e96d24ffbe2ff416 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 9 Jul 2025 20:58:07 +0000 Subject: [PATCH 05/21] ok back up, back up --- site/src/components/Command/Command.tsx | 21 + .../SearchableSelect.stories.tsx | 303 -------- .../SearchableSelect.test.tsx | 315 -------- .../SearchableSelect/SearchableSelect.tsx | 280 -------- site/src/components/Select/Select.tsx | 31 +- .../SelectCombobox/SelectCombobox.stories.tsx | 163 ----- .../SelectCombobox/SelectCombobox.tsx | 680 ------------------ site/src/testHelpers/entities.ts | 379 ++++++---- site/src/theme/mui.ts | 23 +- site/src/theme/roles.ts | 3 + 10 files changed, 280 insertions(+), 1918 deletions(-) delete mode 100644 site/src/components/SearchableSelect/SearchableSelect.stories.tsx delete mode 100644 site/src/components/SearchableSelect/SearchableSelect.test.tsx delete mode 100644 site/src/components/SearchableSelect/SearchableSelect.tsx delete mode 100644 site/src/components/SelectCombobox/SelectCombobox.stories.tsx delete mode 100644 site/src/components/SelectCombobox/SelectCombobox.tsx diff --git a/site/src/components/Command/Command.tsx b/site/src/components/Command/Command.tsx index 54695cdc9f720..88451d13b72ee 100644 --- a/site/src/components/Command/Command.tsx +++ b/site/src/components/Command/Command.tsx @@ -23,6 +23,27 @@ export const Command = forwardRef< /> )); +const CommandDialog: FC = ({ children, ...props }) => { + return ( + + + + {children} + + + + ); +}; + export const CommandInput = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/site/src/components/SearchableSelect/SearchableSelect.stories.tsx b/site/src/components/SearchableSelect/SearchableSelect.stories.tsx deleted file mode 100644 index a15e4d97a64ea..0000000000000 --- a/site/src/components/SearchableSelect/SearchableSelect.stories.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { GitBranch, Globe, Lock, Users } from "lucide-react"; -import { useState } from "react"; -import { - SearchableSelect, - SearchableSelectContent, - SearchableSelectItem, - SearchableSelectTrigger, - SearchableSelectValue, -} from "./SearchableSelect"; - -const meta: Meta = { - title: "components/SearchableSelect", - component: SearchableSelect, - args: { - placeholder: "Select an option", - }, -}; - -export default meta; -type Story = StoryObj; - -const SimpleOptions = () => { - const [value, setValue] = useState(""); - - return ( - - - - - - Option 1 - Option 2 - Option 3 - Option 4 - - - ); -}; - -export const Default: Story = { - render: () => , -}; - -const ManyOptionsExample = () => { - const [value, setValue] = useState(""); - const options = Array.from({ length: 50 }, (_, i) => ({ - value: `option-${i + 1}`, - label: `Option ${i + 1}`, - })); - - return ( - - - - - - {options.map((option) => ( - - {option.label} - - ))} - - - ); -}; - -export const WithManyOptions: Story = { - render: () => , -}; - -const WithIconsExample = () => { - const [value, setValue] = useState(""); - - return ( - - - - - - -
- - Public -
-
- -
- - Private -
-
- -
- - Team only -
-
-
-
- ); -}; - -export const WithIcons: Story = { - render: () => , -}; - -const ProgrammingLanguagesExample = () => { - const [value, setValue] = useState(""); - const languages = [ - "JavaScript", - "TypeScript", - "Python", - "Java", - "C++", - "C#", - "Ruby", - "Go", - "Rust", - "Swift", - "Kotlin", - "Scala", - "PHP", - "Perl", - "R", - "MATLAB", - "Julia", - "Dart", - "Lua", - "Haskell", - "Clojure", - "Elixir", - "F#", - "OCaml", - "Erlang", - "Nim", - "Crystal", - "Zig", - "V", - "Racket", - ]; - - return ( - - - - - - {languages.map((lang) => ( - - {lang} - - ))} - - - ); -}; - -export const ProgrammingLanguages: Story = { - render: () => , -}; - -const DisabledExample = () => { - return ( - - - - - - - Disabled Option - - - - ); -}; - -export const Disabled: Story = { - render: () => , -}; - -const RequiredExample = () => { - const [value, setValue] = useState(""); - - return ( - { - e.preventDefault(); - alert(`Selected: ${value}`); - }} - > -
- - - - - - - Option 1 - - - Option 2 - - - Option 3 - - - - -
- - ); -}; - -export const Required: Story = { - render: () => , -}; - -const EmptyStateExample = () => { - const [value, setValue] = useState(""); - - return ( - - - - - - {/* Intentionally empty to show empty state */} - - - ); -}; - -export const EmptyState: Story = { - render: () => , -}; - -const GitBranchesExample = () => { - const [value, setValue] = useState("main"); - const branches = [ - { name: "main", isDefault: true }, - { name: "develop", isDefault: false }, - { name: "feature/user-authentication", isDefault: false }, - { name: "feature/payment-integration", isDefault: false }, - { name: "bugfix/header-alignment", isDefault: false }, - { name: "hotfix/security-patch", isDefault: false }, - { name: "release/v2.0.0", isDefault: false }, - { name: "chore/update-dependencies", isDefault: false }, - ]; - - return ( - - - - - - {branches.map((branch) => ( - -
- - {branch.name} - {branch.isDefault && ( - - default - - )} -
-
- ))} -
-
- ); -}; - -export const GitBranches: Story = { - render: () => , -}; diff --git a/site/src/components/SearchableSelect/SearchableSelect.test.tsx b/site/src/components/SearchableSelect/SearchableSelect.test.tsx deleted file mode 100644 index 0f75f180f90c6..0000000000000 --- a/site/src/components/SearchableSelect/SearchableSelect.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { - SearchableSelect, - SearchableSelectContent, - SearchableSelectItem, - SearchableSelectTrigger, - SearchableSelectValue, -} from "./SearchableSelect"; - -describe("SearchableSelect", () => { - it("renders with placeholder", () => { - render( - - - - - - Option 1 - - , - ); - - expect(screen.getByText("Select an option")).toBeInTheDocument(); - }); - - it("opens dropdown when trigger is clicked", async () => { - const user = userEvent.setup(); - - render( - - - - - - Option 1 - Option 2 - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.getByText("Option 2")).toBeInTheDocument(); - }); - - it("filters options based on search input", async () => { - const user = userEvent.setup(); - - render( - - - - - - Apple - Banana - Cherry - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const searchInput = screen.getByPlaceholderText("Search..."); - await user.type(searchInput, "ban"); - - expect(screen.getByText("Banana")).toBeInTheDocument(); - expect(screen.queryByText("Apple")).not.toBeInTheDocument(); - expect(screen.queryByText("Cherry")).not.toBeInTheDocument(); - }); - - it("selects an option when clicked", async () => { - const user = userEvent.setup(); - const onValueChange = jest.fn(); - - render( - - - - - - Option 1 - Option 2 - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const option2 = screen.getByText("Option 2"); - await user.click(option2); - - expect(onValueChange).toHaveBeenCalledWith("option2"); - - // Dropdown should close after selection - await waitFor(() => { - expect( - screen.queryByPlaceholderText("Search..."), - ).not.toBeInTheDocument(); - }); - }); - - it("displays selected value", () => { - render( - - - - - - Option 1 - Option 2 - - , - ); - - expect(screen.getByRole("combobox")).toHaveTextContent("Option 2"); - }); - - it("shows check mark for selected option", async () => { - const user = userEvent.setup(); - - render( - - - - - - Option 1 - Option 2 - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - // The selected item should have a check mark (SVG element) - const option2Item = screen.getByText("Option 2").closest('[role="option"]'); - const checkIcon = option2Item?.querySelector("svg"); - expect(checkIcon).toBeInTheDocument(); - }); - - it("shows empty message when no results match search", async () => { - const user = userEvent.setup(); - - render( - - - - - - Apple - Banana - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const searchInput = screen.getByPlaceholderText("Search..."); - await user.type(searchInput, "xyz"); - - expect(screen.getByText("No items found")).toBeInTheDocument(); - }); - - it("clears search when dropdown closes", async () => { - const user = userEvent.setup(); - - render( - - - - - - Apple - Banana - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const searchInput = screen.getByPlaceholderText("Search..."); - await user.type(searchInput, "ban"); - - // Close by clicking outside - await user.click(document.body); - - // Reopen - await user.click(trigger); - - // All options should be visible again - expect(screen.getByText("Apple")).toBeInTheDocument(); - expect(screen.getByText("Banana")).toBeInTheDocument(); - }); - - it("respects disabled state", async () => { - const user = userEvent.setup(); - const onValueChange = jest.fn(); - - render( - - - - - - Option 1 - - , - ); - - const trigger = screen.getByRole("combobox"); - expect(trigger).toBeDisabled(); - - await user.click(trigger); - expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument(); - expect(onValueChange).not.toHaveBeenCalled(); - }); - - it("supports custom id", () => { - render( - - - - - - Option 1 - - , - ); - - expect(document.getElementById("my-select")).toBeInTheDocument(); - expect(document.getElementById("my-trigger")).toBeInTheDocument(); - }); - - it("filters by option value when text doesn't match", async () => { - const user = userEvent.setup(); - - render( - - - - - - - US East (N. Virginia) - - - EU (Ireland) - - - Asia Pacific (Singapore) - - - , - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const searchInput = screen.getByPlaceholderText("Search..."); - await user.type(searchInput, "west"); - - // Should find the EU option by its value - expect(screen.getByText("EU (Ireland)")).toBeInTheDocument(); - expect(screen.queryByText("US East (N. Virginia)")).not.toBeInTheDocument(); - expect( - screen.queryByText("Asia Pacific (Singapore)"), - ).not.toBeInTheDocument(); - }); - - it("supports complex content in items", async () => { - const user = userEvent.setup(); - - render( - - - - - - -
- 🍎 - Apple -
-
- -
- 🍌 - Banana -
-
-
-
, - ); - - const trigger = screen.getByRole("combobox"); - await user.click(trigger); - - const searchInput = screen.getByPlaceholderText("Search..."); - await user.type(searchInput, "apple"); - - // Should still find Apple even with complex structure - expect(screen.getByText("Apple")).toBeInTheDocument(); - expect(screen.queryByText("Banana")).not.toBeInTheDocument(); - }); -}); diff --git a/site/src/components/SearchableSelect/SearchableSelect.tsx b/site/src/components/SearchableSelect/SearchableSelect.tsx deleted file mode 100644 index 7b1c12f04005d..0000000000000 --- a/site/src/components/SearchableSelect/SearchableSelect.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "components/Command/Command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "components/Popover/Popover"; -import { Check, ChevronDown, Search } from "lucide-react"; -import * as React from "react"; -import { useEffect, useRef, useState } from "react"; -import { cn } from "utils/cn"; - -interface SearchableSelectProps { - value?: string; - onValueChange?: (value: string) => void; - placeholder?: string; - disabled?: boolean; - required?: boolean; - id?: string; - children?: React.ReactNode; - className?: string; - emptyMessage?: string; -} - -interface SearchableSelectTriggerProps { - id?: string; - children?: React.ReactNode; - className?: string; -} - -interface SearchableSelectContentProps { - children?: React.ReactNode; - className?: string; -} - -interface SearchableSelectItemProps { - value: string; - children?: React.ReactNode; - className?: string; -} - -interface SearchableSelectValueProps { - placeholder?: string; - className?: string; -} - -// Context to share state between compound components -interface SearchableSelectContextValue { - value?: string; - onValueChange?: (value: string) => void; - open: boolean; - setOpen: (open: boolean) => void; - disabled?: boolean; - placeholder?: string; - items: Map; - setSearch: (search: string) => void; - search: string; - emptyMessage?: string; -} - -const SearchableSelectContext = React.createContext< - SearchableSelectContextValue | undefined ->(undefined); - -const useSearchableSelectContext = () => { - const context = React.useContext(SearchableSelectContext); - if (!context) { - throw new Error( - "SearchableSelect components must be used within SearchableSelect", - ); - } - return context; -}; - -export const SearchableSelect: React.FC = ({ - value, - onValueChange, - placeholder = "Select option", - disabled = false, - required = false, - id, - children, - className, - emptyMessage = "No results found", -}) => { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const items = useRef(new Map()).current; - - // Clear search when closing - useEffect(() => { - if (!open) { - setSearch(""); - } - }, [open]); - - const contextValue: SearchableSelectContextValue = { - value, - onValueChange, - open, - setOpen, - disabled, - placeholder, - items, - setSearch, - search, - emptyMessage, - }; - - return ( - -
- {children} -
-
- ); -}; - -export const SearchableSelectTrigger = React.forwardRef< - HTMLButtonElement, - SearchableSelectTriggerProps ->(({ id, children, className }, ref) => { - const { open, setOpen, disabled } = useSearchableSelectContext(); - - return ( - - - - - - ); -}); -SearchableSelectTrigger.displayName = "SearchableSelectTrigger"; - -export const SearchableSelectValue: React.FC = ({ - placeholder, - className, -}) => { - const { - value, - items, - placeholder: contextPlaceholder, - } = useSearchableSelectContext(); - const displayPlaceholder = placeholder || contextPlaceholder; - - return ( - <> - - {value ? items.get(value) || value : displayPlaceholder} - - - - ); -}; - -export const SearchableSelectContent: React.FC< - SearchableSelectContentProps -> = ({ children, className }) => { - const { setSearch, search, emptyMessage } = useSearchableSelectContext(); - - return ( - - -
- - -
- - - {emptyMessage} - - {children} - -
-
- ); -}; - -export const SearchableSelectItem: React.FC = ({ - value, - children, - className, -}) => { - const { - value: selectedValue, - onValueChange, - setOpen, - items, - search, - } = useSearchableSelectContext(); - - // Register item content - useEffect(() => { - items.set(value, children); - return () => { - items.delete(value); - }; - }, [value, children, items]); - - // Simple search filter - const searchableText = React.Children.toArray(children) - .map((child) => { - if (typeof child === "string") return child; - if ( - React.isValidElement(child) && - typeof child.props.children === "string" - ) { - return child.props.children; - } - return ""; - }) - .join(" ") - .toLowerCase(); - - const isVisible = - !search || - searchableText.includes(search.toLowerCase()) || - value.toLowerCase().includes(search.toLowerCase()); - - if (!isVisible) { - return null; - } - - return ( - { - onValueChange?.(value); - setOpen(false); - }} - className={cn( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5", - "pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary", - "focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className, - )} - > - {children} - {selectedValue === value && ( - - - - )} - - ); -}; diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index be9695e8d354b..3d2f8ffc3b706 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -9,26 +9,15 @@ import { cn } from "utils/cn"; export const Select = SelectPrimitive.Root; -type SearchableSelectContext = { query: string, setQuery: (next: string) => void }; -const SearchableSelectContext = React.createContext({ query: "", setQuery: () => { } }); -export const SearchableSelect: React.FC = (({ children, ...props }) => { - const [query, setQuery] = React.useState(""); - - return - - {children} - - -}); -SearchableSelect.displayName = SelectPrimitive.Root.displayName; - export const SelectGroup = SelectPrimitive.Group; export const SelectValue = SelectPrimitive.Value; export const SelectTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + id?: string; + } >(({ className, children, id, ...props }, ref) => ( {children} diff --git a/site/src/components/SelectCombobox/SelectCombobox.stories.tsx b/site/src/components/SelectCombobox/SelectCombobox.stories.tsx deleted file mode 100644 index b1400ded5b015..0000000000000 --- a/site/src/components/SelectCombobox/SelectCombobox.stories.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { expect, userEvent, waitFor, within } from "@storybook/test"; -import { MockOrganization, MockOrganization2 } from "testHelpers/entities"; -import { SelectCombobox } from "./SelectCombobox"; - -const organizations = [MockOrganization, MockOrganization2]; - -const meta: Meta = { - title: "components/SelectCombobox", - component: SelectCombobox, - args: { - hidePlaceholderWhenSelected: true, - placeholder: "Select organization", - emptyIndicator: ( -

- All organizations selected -

- ), - options: organizations.map((org) => ({ - label: org.display_name, - value: org.id, - })), - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const OpenCombobox: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - - await waitFor(() => - expect(canvas.getByText("My Organization")).toBeInTheDocument(), - ); - }, -}; - -export const WithIcons: Story = { - args: { - options: organizations.map((org) => ({ - label: org.display_name, - value: org.id, - icon: org.icon, - })), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - await waitFor(() => - expect(canvas.getByText("My Organization")).toBeInTheDocument(), - ); - }, -}; - -export const SelectComboboxItem: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization" }), - ); - }, -}; - -export const SelectAllComboboxItems: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization" }), - ); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization 2" }), - ); - - await waitFor(() => - expect( - canvas.getByText("All organizations selected"), - ).toBeInTheDocument(), - ); - }, -}; - -export const ClearFirstSelectedItem: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization" }), - ); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization 2" }), - ); - await userEvent.click(canvas.getAllByTestId("clear-option-button")[0]); - }, -}; - -export const ClearAllComboboxItems: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Select organization")); - await userEvent.click( - canvas.getByRole("option", { name: "My Organization" }), - ); - await userEvent.click(canvas.getByTestId("clear-all-button")); - - await waitFor(() => - expect( - canvas.getByPlaceholderText("Select organization"), - ).toBeInTheDocument(), - ); - }, -}; - -export const WithGroups: Story = { - args: { - placeholder: "Make a playlist", - groupBy: "album", - options: [ - { - label: "Photo Facing Water", - value: "photo-facing-water", - album: "Papillon", - icon: "/emojis/1f301.png", - }, - { - label: "Mercurial", - value: "mercurial", - album: "Papillon", - icon: "/emojis/1fa90.png", - }, - { - label: "Merging", - value: "merging", - album: "Papillon", - icon: "/lol-not-a-real-image.png", - }, - { - label: "Flacks", - value: "flacks", - album: "aBliss", - // intentionally omitted icon - }, - { - label: "aBliss", - value: "abliss", - album: "aBliss", - // intentionally omitted icon - }, - ], - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByPlaceholderText("Make a playlist")); - await waitFor(() => - expect(canvas.getByText("Papillon")).toBeInTheDocument(), - ); - }, -}; diff --git a/site/src/components/SelectCombobox/SelectCombobox.tsx b/site/src/components/SelectCombobox/SelectCombobox.tsx deleted file mode 100644 index d5fd5fec15e6f..0000000000000 --- a/site/src/components/SelectCombobox/SelectCombobox.tsx +++ /dev/null @@ -1,680 +0,0 @@ -/** - * This component is based on multiple-selector - * @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector} - */ -import { Command as CommandPrimitive, useCommandState } from "cmdk"; -import { Avatar } from "components/Avatar/Avatar"; -import { Badge } from "components/Badge/Badge"; -import { - Command, - CommandGroup, - CommandItem, - CommandList, -} from "components/Command/Command"; -import { useDebouncedValue } from "hooks/debounce"; -import { ChevronDown, X } from "lucide-react"; -import { - type ComponentProps, - type ComponentPropsWithoutRef, - type KeyboardEvent, - type ReactNode, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import { cn } from "utils/cn"; - -export interface Option { - value: string; - label: string; - icon?: string; - disable?: boolean; - /** fixed option that can't be removed. */ - fixed?: boolean; - /** Group the options by providing key. */ - [key: string]: string | boolean | undefined; -} -interface GroupOption { - [key: string]: Option[]; -} - -interface SelectComboboxProps { - value?: Option[]; - defaultOptions?: Option[]; - /** manually controlled options */ - options?: Option[]; - placeholder?: string; - /** Loading component. */ - loadingIndicator?: ReactNode; - /** Empty component. */ - emptyIndicator?: ReactNode; - /** Debounce time for async search. Only work with `onSearch`. */ - delay?: number; - /** - * Only work with `onSearch` prop. Trigger search when `onFocus`. - * For example, when user click on the input, it will trigger the search to get initial options. - **/ - triggerSearchOnFocus?: boolean; - /** async search */ - onSearch?: (value: string) => Promise; - /** - * sync search. This search will not showing loadingIndicator. - * The rest props are the same as async search. - * i.e.: creatable, groupBy, delay. - **/ - onSearchSync?: (value: string) => Option[]; - onChange?: (options: Option[]) => void; - /** Limit the maximum number of selected options. */ - maxSelected?: number; - /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ - onMaxSelected?: (maxLimit: number) => void; - /** Hide the placeholder when there are options selected. */ - hidePlaceholderWhenSelected?: boolean; - disabled?: boolean; - /** Group the options base on provided key. */ - groupBy?: string; - className?: string; - badgeClassName?: string; - /** - * First item selected is a default behavior by cmdk. That is why the default is true. - * This is a workaround solution by add a dummy item. - * - * @see {@link https://github.com/pacocoursey/cmdk/issues/171} - */ - selectFirstItem?: boolean; - /** Allow user to create option when there is no option matched. */ - creatable?: boolean; - /** Props of `Command` */ - commandProps?: ComponentPropsWithoutRef; - /** Props of `CommandInput` */ - inputProps?: Omit< - ComponentPropsWithoutRef, - "value" | "placeholder" | "disabled" - >; - /** hide or show the button that clears all the selected options. */ - hideClearAllButton?: boolean; -} - -interface SelectComboboxRef { - selectedValue: Option[]; - input: HTMLInputElement; - focus: () => void; - reset: () => void; -} - -function transitionToGroupOption(options: Option[], groupBy?: string) { - if (options.length === 0) { - return {}; - } - if (!groupBy) { - return { - "": options, - }; - } - - const groupOption: GroupOption = {}; - for (const option of options) { - const key = (option[groupBy] as string) || ""; - if (!groupOption[key]) { - groupOption[key] = []; - } - groupOption[key].push(option); - } - return groupOption; -} - -function removePickedOption(groupOption: GroupOption, picked: Option[]) { - const cloneOption = structuredClone(groupOption); - - for (const [key, value] of Object.entries(cloneOption)) { - cloneOption[key] = value.filter( - (val) => !picked.find((p) => p.value === val.value), - ); - } - return cloneOption; -} - -function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { - return Object.values(groupOption).some((value) => - value.some((option) => targetOption.some((o) => o.value === option.value)), - ); -} - -/** - * The `CommandEmpty` of shadcn/ui will cause the cmdk-empty to not render correctly. - * Here a new CommandEmpty is created using the `Empty` implementation from `cmdk`. - * - * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 - **/ -const CommandEmpty = forwardRef< - HTMLDivElement, - ComponentProps ->(({ className, ...props }, forwardedRef) => { - const render = useCommandState((state) => state.filtered.count === 0); - - if (!render) return null; - - return ( -
- ); -}); - -export const SelectCombobox = forwardRef< - SelectComboboxRef, - SelectComboboxProps ->( - ( - { - value, - onChange, - placeholder, - defaultOptions: arrayDefaultOptions = [], - options: arrayOptions, - delay, - onSearch, - onSearchSync, - loadingIndicator, - emptyIndicator, - maxSelected = Number.MAX_SAFE_INTEGER, - onMaxSelected, - hidePlaceholderWhenSelected, - disabled, - groupBy, - className, - badgeClassName, - selectFirstItem = true, - creatable = false, - triggerSearchOnFocus = false, - commandProps, - inputProps, - hideClearAllButton = false, - }: SelectComboboxProps, - ref, - ) => { - const inputRef = useRef(null); - const [open, setOpen] = useState(false); - const [onScrollbar, setOnScrollbar] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const dropdownRef = useRef(null); - - const [selected, setSelected] = useState( - arrayDefaultOptions ?? [], - ); - const [options, setOptions] = useState( - transitionToGroupOption(arrayDefaultOptions, groupBy), - ); - const [inputValue, setInputValue] = useState(""); - const debouncedSearchTerm = useDebouncedValue(inputValue, delay || 500); - - const [previousValue, setPreviousValue] = useState(value || []); - if (value && value !== previousValue) { - setPreviousValue(value); - setSelected(value); - } - - useImperativeHandle( - ref, - () => ({ - selectedValue: [...selected], - input: inputRef.current as HTMLInputElement, - focus: () => inputRef?.current?.focus(), - reset: () => setSelected([]), - }), - [selected], - ); - - const handleUnselect = useCallback( - (option: Option) => { - const newOptions = selected.filter((s) => s.value !== option.value); - setSelected(newOptions); - onChange?.(newOptions); - }, - [onChange, selected], - ); - - const handleKeyDown = (e: KeyboardEvent) => { - const input = inputRef.current; - if (input) { - if (e.key === "Delete" || e.key === "Backspace") { - if (input.value === "" && selected.length > 0) { - const lastSelectOption = selected[selected.length - 1]; - // If last item is fixed, we should not remove it. - if (!lastSelectOption.fixed) { - handleUnselect(selected[selected.length - 1]); - } - } - } - // This is not a default behavior of the field - if (e.key === "Escape") { - input.blur(); - } - } - }; - - useEffect(() => { - if (!open) { - return; - } - - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - inputRef.current && - !inputRef.current.contains(event.target as Node) - ) { - setOpen(false); - inputRef.current.blur(); - } - }; - - if (open) { - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("touchend", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("touchend", handleClickOutside); - }; - }, [open]); - - useEffect(() => { - /** If `onSearch` is provided, do not trigger options updated. */ - if (!arrayOptions || onSearch) { - return; - } - const newOption = transitionToGroupOption(arrayOptions || [], groupBy); - if (JSON.stringify(newOption) !== JSON.stringify(options)) { - setOptions(newOption); - } - }, [arrayOptions, groupBy, onSearch, options]); - - useEffect(() => { - /** sync search */ - - const doSearchSync = () => { - const res = onSearchSync?.(debouncedSearchTerm); - setOptions(transitionToGroupOption(res || [], groupBy)); - }; - - const exec = () => { - if (!onSearchSync || !open) return; - - if (triggerSearchOnFocus) { - doSearchSync(); - } - - if (debouncedSearchTerm) { - doSearchSync(); - } - }; - - void exec(); - }, [ - debouncedSearchTerm, - groupBy, - open, - triggerSearchOnFocus, - onSearchSync, - ]); - - useEffect(() => { - /** async search */ - - const doSearch = async () => { - setIsLoading(true); - const res = await onSearch?.(debouncedSearchTerm); - setOptions(transitionToGroupOption(res || [], groupBy)); - setIsLoading(false); - }; - - const exec = async () => { - if (!onSearch || !open) return; - - if (triggerSearchOnFocus) { - await doSearch(); - } - - if (debouncedSearchTerm) { - await doSearch(); - } - }; - - void exec(); - }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]); - - const CreatableItem = () => { - if (!creatable) { - return undefined; - } - if ( - isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || - selected.find((s) => s.value === inputValue) - ) { - return undefined; - } - - const Item = ( - { - e.preventDefault(); - e.stopPropagation(); - }} - onSelect={(value: string) => { - if (selected.length >= maxSelected) { - onMaxSelected?.(selected.length); - return; - } - setInputValue(""); - const newOptions = [...selected, { value, label: value }]; - setSelected(newOptions); - onChange?.(newOptions); - }} - > - Create "{inputValue}" - - ); - - // For normal creatable - if (!onSearch && inputValue.length > 0) { - return Item; - } - - // For async search creatable. avoid showing creatable item before loading at first. - if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { - return Item; - } - - return undefined; - }; - - const EmptyItem = useCallback(() => { - if (!emptyIndicator) return undefined; - - // For async search that showing emptyIndicator - if (onSearch && !creatable && Object.keys(options).length === 0) { - return ( - - {emptyIndicator} - - ); - } - - return {emptyIndicator}; - }, [creatable, emptyIndicator, onSearch, options]); - - const selectables = useMemo( - () => removePickedOption(options, selected), - [options, selected], - ); - - /** Avoid Creatable Selector freezing or lagging when paste a long string. */ - const commandFilter = () => { - if (commandProps?.filter) { - return commandProps.filter; - } - - if (creatable) { - return (value: string, search: string) => { - return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; - }; - } - // Using default filter in `cmdk`. We don't have to provide it. - return undefined; - }; - - if (inputRef.current && inputProps?.id) { - inputRef.current.id = inputProps?.id; - } - - const fixedOptions = selected.filter((s) => s.fixed); - const showIcons = arrayOptions?.some((it) => it.icon); - - return ( - { - handleKeyDown(e); - commandProps?.onKeyDown?.(e); - }} - className={cn( - "h-auto overflow-visible bg-transparent", - commandProps?.className, - )} - shouldFilter={ - commandProps?.shouldFilter !== undefined - ? commandProps.shouldFilter - : !onSearch - } // When onSearch is provided, we don't want to filter the options. You can still override it. - filter={commandFilter()} - > - {/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */} -
{ - if (disabled) return; - inputRef?.current?.focus(); - }} - > -
-
- {selected.map((option) => { - return ( - -
- {option.icon && ( - - )} - {option.label} -
- -
- ); - })} - {/* Avoid having the "Search" Icon */} - { - setInputValue(value); - inputProps?.onValueChange?.(value); - }} - onBlur={(event) => { - if (!onScrollbar) { - setOpen(false); - } - inputProps?.onBlur?.(event); - }} - onFocus={(event) => { - setOpen(true); - triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); - inputProps?.onFocus?.(event); - }} - placeholder={ - hidePlaceholderWhenSelected && selected.length !== 0 - ? "" - : placeholder - } - className={cn( - "flex-1 border-none outline-none bg-transparent placeholder:text-content-secondary", - { - "w-full": hidePlaceholderWhenSelected, - "px-3 py-2.5": selected.length === 0, - "ml-1": selected.length !== 0, - }, - inputProps?.className, - )} - /> -
-
- - -
-
-
-
- {open && ( - { - setOnScrollbar(false); - }} - onPointerEnter={() => { - setOnScrollbar(true); - }} - onMouseUp={() => { - inputRef?.current?.focus(); - }} - > - {isLoading ? ( - <>{loadingIndicator} - ) : ( - <> - {EmptyItem()} - {CreatableItem()} - {!selectFirstItem && ( - - )} - {Object.entries(selectables).map(([key, dropdowns]) => ( - - {/* biome-ignore lint/complexity/noUselessFragments: A parent element is - needed for multiple dropdown items */} - <> - {dropdowns.map((option) => { - return ( - { - e.preventDefault(); - e.stopPropagation(); - }} - onSelect={() => { - if (selected.length >= maxSelected) { - onMaxSelected?.(selected.length); - return; - } - setInputValue(""); - const newOptions = [...selected, option]; - setSelected(newOptions); - onChange?.(newOptions); - }} - className={cn( - "cursor-pointer", - option.disable && - "cursor-default text-content-disabled", - )} - > -
- {showIcons && ( - - )} - {option.label} -
-
- ); - })} - -
- ))} - - )} -
- )} -
-
- ); - }, -); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a16b10be5336e..22dc47ae2390f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -550,18 +550,18 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { }; export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = -{ - organization_id: MockOrganization.id, - user_id: MockUserMember.id, - username: MockUserMember.username, - email: MockUserMember.email, - updated_at: "2025-05-22T17:51:49.49745Z", - created_at: "2025-05-22T17:51:49.497449Z", - name: MockUserMember.name, - avatar_url: MockUserMember.avatar_url, - global_roles: MockUserMember.roles, - roles: [], -}; + { + organization_id: MockOrganization.id, + user_id: MockUserMember.id, + username: MockUserMember.username, + email: MockUserMember.email, + updated_at: "2025-05-22T17:51:49.49745Z", + created_at: "2025-05-22T17:51:49.497449Z", + name: MockUserMember.name, + avatar_url: MockUserMember.avatar_url, + global_roles: MockUserMember.roles, + roles: [], + }; export const MockProvisionerKey: TypesGen.ProvisionerKey = { id: "test-provisioner-key", @@ -753,11 +753,11 @@ You can add instructions here }; export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = -{ - ...MockTemplateVersion, - id: "test-template-version-markdown", - name: "test-version-markdown", - message: ` + { + ...MockTemplateVersion, + id: "test-template-version-markdown", + name: "test-version-markdown", + message: ` # Abiding Grace ## Enchantment At the beginning of your end step, choose one — @@ -766,7 +766,7 @@ At the beginning of your end step, choose one — - Return target creature card with mana value 1 from your graveyard to the battlefield. `, -}; + }; export const MockTemplate: TypesGen.Template = { id: "test-template", @@ -1243,16 +1243,16 @@ export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { }; const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = -{ - schedule: "", -}; + { + schedule: "", + }; const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = -{ - // Runs at 9:30am Monday through Friday using Canada/Eastern - // (America/Toronto) time - schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", -}; + { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", + }; export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { build_number: 1, @@ -1536,13 +1536,13 @@ const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { }; export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = -{ - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: "stopped", - }, -}; + { + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, + }; const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockOutdatedRunningWorkspaceAlwaysUpdate, @@ -1573,71 +1573,75 @@ export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { count: 26, }; +const MockWorkspacesResponseWithDeletions = { + workspaces: [...MockWorkspacesResponse.workspaces, MockWorkspaceWithDeletion], + count: MockWorkspacesResponse.count + 1, +}; export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = -{ - name: "first_parameter", - type: "string", - form_type: "input", - description: "This is first parameter", - description_plaintext: "Markdown: This is first parameter", - default_value: "abc", - mutable: true, - icon: "/icon/folder.svg", - options: [], - required: true, - ephemeral: false, -}; + { + name: "first_parameter", + type: "string", + form_type: "input", + description: "This is first parameter", + description_plaintext: "Markdown: This is first parameter", + default_value: "abc", + mutable: true, + icon: "/icon/folder.svg", + options: [], + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = -{ - name: "second_parameter", - type: "number", - form_type: "input", - description: "This is second parameter", - description_plaintext: "Markdown: This is second parameter", - default_value: "2", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 3, - validation_monotonic: "increasing", - required: true, - ephemeral: false, -}; + { + name: "second_parameter", + type: "number", + form_type: "input", + description: "This is second parameter", + description_plaintext: "Markdown: This is second parameter", + default_value: "2", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 3, + validation_monotonic: "increasing", + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = -{ - name: "third_parameter", - type: "string", - form_type: "input", - description: "This is third parameter", - description_plaintext: "Markdown: This is third parameter", - default_value: "aaa", - mutable: true, - icon: "/icon/database.svg", - options: [], - validation_error: "No way!", - validation_regex: "^[a-z]{3}$", - required: true, - ephemeral: false, -}; + { + name: "third_parameter", + type: "string", + form_type: "input", + description: "This is third parameter", + description_plaintext: "Markdown: This is third parameter", + default_value: "aaa", + mutable: true, + icon: "/icon/database.svg", + options: [], + validation_error: "No way!", + validation_regex: "^[a-z]{3}$", + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = -{ - name: "fourth_parameter", - type: "string", - form_type: "input", - description: "This is fourth parameter", - description_plaintext: "Markdown: This is fourth parameter", - default_value: "def", - mutable: false, - icon: "/icon/database.svg", - options: [], - required: true, - ephemeral: false, -}; + { + name: "fourth_parameter", + type: "string", + form_type: "input", + description: "This is fourth parameter", + description_plaintext: "Markdown: This is fourth parameter", + default_value: "def", + mutable: false, + icon: "/icon/database.svg", + options: [], + required: true, + ephemeral: false, + }; const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = { name: "fifth_parameter", @@ -1713,16 +1717,16 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { }; export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = -{ - name: "test", - template_version_id: "test-template-version", - rich_parameter_values: [ - { - name: MockTemplateVersionParameter1.name, - value: MockTemplateVersionParameter1.default_value, - }, - ], -}; + { + name: "test", + template_version_id: "test-template-version", + rich_parameter_values: [ + { + name: MockTemplateVersionParameter1.name, + value: MockTemplateVersionParameter1.default_value, + }, + ], + }; const MockUserAgent = { browser: "Chrome 99.0.4844", @@ -2406,6 +2410,30 @@ export const MockEntitlements: TypesGen.Entitlements = { refreshed_at: "2022-05-20T16:45:57.122Z", }; +const MockEntitlementsWithWarnings: TypesGen.Entitlements = { + errors: [], + warnings: ["You are over your active user limit.", "And another thing."], + has_license: true, + trial: false, + require_telemetry: false, + refreshed_at: "2022-05-20T16:45:57.122Z", + features: withDefaultFeatures({ + user_limit: { + enabled: true, + entitlement: "grace_period", + limit: 100, + actual: 102, + }, + audit_log: { + enabled: true, + entitlement: "entitled", + }, + browser_only: { + enabled: true, + entitlement: "entitled", + }, + }), +}; export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { errors: [], @@ -2437,6 +2465,21 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { }), }; +const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", + features: withDefaultFeatures({ + user_limit: { + enabled: true, + entitlement: "entitled", + limit: 25, + }, + }), +}; export const MockEntitlementsWithMultiOrg: TypesGen.Entitlements = { ...MockEntitlements, @@ -2599,6 +2642,42 @@ export const MockAuditLogGitSSH: TypesGen.AuditLog = { }, }; +const MockAuditOauthConvert: TypesGen.AuditLog = { + ...MockAuditLog, + resource_type: "convert_login", + resource_target: "oidc", + action: "create", + status_code: 201, + description: "{user} created login type conversion to {target}}", + diff: { + created_at: { + old: "0001-01-01T00:00:00Z", + new: "2023-06-20T20:44:54.243019Z", + secret: false, + }, + expires_at: { + old: "0001-01-01T00:00:00Z", + new: "2023-06-20T20:49:54.243019Z", + secret: false, + }, + state_string: { + old: "", + new: "", + secret: true, + }, + to_type: { + old: "", + new: "oidc", + secret: false, + }, + user_id: { + old: "", + new: "dc790496-eaec-4f88-a53f-8ce1f61a1fff", + secret: false, + }, + }, +}; + export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = { ...MockAuditLog, resource_type: "api_key", @@ -2700,21 +2779,21 @@ export const MockOrganizationSyncSettings: TypesGen.OrganizationSyncSettings = { }; export const MockOrganizationSyncSettings2: TypesGen.OrganizationSyncSettings = -{ - field: "organization-test", - mapping: { - "idp-org-1": ["my-organization-id", "my-organization-2-id"], - "idp-org-2": ["my-organization-id"], - }, - organization_assign_default: true, -}; + { + field: "organization-test", + mapping: { + "idp-org-1": ["my-organization-id", "my-organization-2-id"], + "idp-org-2": ["my-organization-id"], + }, + organization_assign_default: true, + }; export const MockOrganizationSyncSettingsEmpty: TypesGen.OrganizationSyncSettings = -{ - field: "", - mapping: {}, - organization_assign_default: true, -}; + { + field: "", + mapping: {}, + organization_assign_default: true, + }; export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", @@ -2947,24 +3026,24 @@ export const MockPreviewParameter: TypesGen.PreviewParameter = { }; export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = -{ - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: false, - display_icon: "/icon/github.svg", - display_name: "GitHub", -}; + { + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", + }; export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = -{ - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: true, - display_icon: "/icon/github.svg", - display_name: "GitHub", -}; + { + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: true, + display_icon: "/icon/github.svg", + display_name: "GitHub", + }; export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", @@ -3885,13 +3964,13 @@ export const MockHealth: TypesGen.HealthcheckReport = { }; export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = -{ - ports: [ - { process_name: "webb", network: "", port: 30000 }, - { process_name: "gogo", network: "", port: 8080 }, - { process_name: "", network: "", port: 8081 }, - ], -}; + { + ports: [ + { process_name: "webb", network: "", port: 30000 }, + { process_name: "gogo", network: "", port: 8080 }, + { process_name: "", network: "", port: 8081 }, + ], + }; export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { shares: [ @@ -4300,20 +4379,20 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = { }; export const MockWorkspaceAgentDevcontainer: TypesGen.WorkspaceAgentDevcontainer = -{ - id: "test-devcontainer-id", - name: "test-devcontainer", - workspace_folder: "/workspace/test", - config_path: "/workspace/test/.devcontainer/devcontainer.json", - status: "running", - dirty: false, - container: MockWorkspaceAgentContainer, - agent: { - id: MockWorkspaceSubAgent.id, - name: MockWorkspaceSubAgent.name, - directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test", - }, -}; + { + id: "test-devcontainer-id", + name: "test-devcontainer", + workspace_folder: "/workspace/test", + config_path: "/workspace/test/.devcontainer/devcontainer.json", + status: "running", + dirty: false, + container: MockWorkspaceAgentContainer, + agent: { + id: MockWorkspaceSubAgent.id, + name: MockWorkspaceSubAgent.name, + directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test", + }, + }; export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [ { diff --git a/site/src/theme/mui.ts b/site/src/theme/mui.ts index 5208ba0090c92..346ca90bcd04c 100644 --- a/site/src/theme/mui.ts +++ b/site/src/theme/mui.ts @@ -12,6 +12,17 @@ import { } from "./constants"; import tw from "./tailwindColors"; +type PaletteIndex = + | "primary" + | "secondary" + | "background" + | "text" + | "error" + | "warning" + | "info" + | "success" + | "action" + | "neutral"; // biome-ignore lint/suspicious/noExplicitAny: needed for MUI overrides type MuiStyle = any; @@ -213,9 +224,9 @@ export const components = { // This targets the first+last td elements, and also the first+last elements // of a TableCellLink. "&:not(:only-child):first-of-type, &:not(:only-child):first-of-type > a": - { - paddingLeft: 32, - }, + { + paddingLeft: 32, + }, "&:not(:only-child):last-child, &:not(:only-child):last-child > a": { paddingRight: 32, }, @@ -353,9 +364,9 @@ export const components = { }, // The default outlined input color is white, which seemed jarring. "&:hover:not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": - { - borderColor: tw.zinc[500], - }, + { + borderColor: tw.zinc[500], + }, }, }, }, diff --git a/site/src/theme/roles.ts b/site/src/theme/roles.ts index f8f4866df3bbc..b83bd6ad15f09 100644 --- a/site/src/theme/roles.ts +++ b/site/src/theme/roles.ts @@ -1,5 +1,8 @@ export type ThemeRole = keyof Roles; +type InteractiveThemeRole = keyof { + [K in keyof Roles as Roles[K] extends InteractiveRole ? K : never]: unknown; +}; export interface Roles { /** Something is wrong; either unexpectedly, or in a meaningful way. */ From 01591de2e84cd964189fd6df0e44453f9521452e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 9 Jul 2025 21:46:40 +0000 Subject: [PATCH 06/21] I FINALLY WIN --- site/src/components/Combobox/Combobox.tsx | 23 ++- .../DynamicParameter.stories.tsx | 165 +++++------------- .../DynamicParameter/DynamicParameter.tsx | 114 ++++++------ 3 files changed, 124 insertions(+), 178 deletions(-) diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index fa15b6808a05e..7390f867a0204 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -18,7 +18,7 @@ import { cn } from "utils/cn"; interface ComboboxProps { value: string; - options?: readonly string[]; + options?: Readonly>; placeholder?: string; open: boolean; onOpenChange: (open: boolean) => void; @@ -28,6 +28,16 @@ interface ComboboxProps { onSelect: (value: string) => void; } +type ComboboxOption = { + icon?: string; + displayName: string; + value: string; +}; + +function normalizeOptions(options: Readonly>): readonly ComboboxOption[] { +return options.map((option) => typeof options === "string" ? ({ displayName: option, value: option }) : option); +} + export const Combobox: FC = ({ value, options = [], @@ -70,16 +80,17 @@ export const Combobox: FC = ({ - {options.map((option) => ( + {normalizeOptions(options).map((option) => ( { onSelect(currentValue === value ? "" : currentValue); }} > - {option} - {value === option && ( + {option.displayName} + {value === option.value && ( )} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index e1baa28e72ed2..89d7078afa15f 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -10,7 +10,7 @@ const meta: Meta = { }, args: { parameter: MockPreviewParameter, - onChange: () => {}, + onChange: () => { }, }, }; @@ -56,154 +56,77 @@ export const Dropdown: Story = { type: "string", options: [ { - name: "Option 1", - value: { valid: true, value: "option1" }, - description: "this is option 1", - icon: "", - }, - { - name: "Option 2", - value: { valid: true, value: "option2" }, - description: "this is option 2", - icon: "", - }, - { - name: "Option 3", - value: { valid: true, value: "option3" }, - description: "this is option 3", - icon: "", - }, - ], - }, - }, -}; - -export const DropdownWithManyOptions: Story = { - args: { - parameter: { - ...MockPreviewParameter, - form_type: "dropdown", - type: "string", - options: [ - { - name: "JavaScript", - value: { valid: true, value: "javascript" }, - description: "JavaScript programming language", - icon: "", + name: "Go", + value: { valid: true, value: "go" }, + description: "Go 1.24, gofumpt, golangci-lint", + icon: "/icon/go.svg", }, { - name: "TypeScript", - value: { valid: true, value: "typescript" }, - description: "TypeScript programming language", - icon: "", + name: "Kotlin/Java", + value: { valid: true, value: "jvm" }, + description: "OpenJDK 24 and Gradle", + icon: "/icon/kotlin.svg", }, { - name: "Python", - value: { valid: true, value: "python" }, - description: "Python programming language", - icon: "", + name: "Rust", + value: { valid: true, value: "rust" }, + description: "rustup w/ stable and nightly toolchains", + icon: "/icon/rust.svg", }, { - name: "Java", - value: { valid: true, value: "java" }, - description: "Java programming language", - icon: "", + name: "TypeScript/JavaScript", + value: { valid: true, value: "js" }, + description: "Node.js 24, fnm, and npm/yarn/pnpm via corepack", + icon: "/icon/typescript.svg", }, + { name: "C++", value: { valid: true, value: "cpp" }, - description: "C++ programming language", - icon: "", + description: "gcc 15.1, CMake, ninja, autotools, vcpkg, clang-format", + icon: "/icon/cpp.svg", }, { name: "C#", value: { valid: true, value: "csharp" }, - description: "C# programming language", - icon: "", - }, - { - name: "Ruby", - value: { valid: true, value: "ruby" }, - description: "Ruby programming language", - icon: "", - }, - { - name: "Go", - value: { valid: true, value: "go" }, - description: "Go programming language", - icon: "", + description: ".NET 9", + icon: "/icon/dotnet.svg", }, { - name: "Rust", - value: { valid: true, value: "rust" }, - description: "Rust programming language", - icon: "", - }, - { - name: "Swift", - value: { valid: true, value: "swift" }, - description: "Swift programming language", - icon: "", - }, - { - name: "Kotlin", - value: { valid: true, value: "kotlin" }, - description: "Kotlin programming language", - icon: "", + name: "Dart", + value: { valid: true, value: "dart" }, + description: "Dart 3", + icon: "https://github.com/dart-lang.png", }, { - name: "Scala", - value: { valid: true, value: "scala" }, - description: "Scala programming language", - icon: "", + name: "Julia", + value: { valid: true, value: "julia" }, + description: "Julia 1.10", + icon: "https://github.com/JuliaLang.png", }, { name: "PHP", value: { valid: true, value: "php" }, - description: "PHP programming language", - icon: "", - }, - { - name: "Perl", - value: { valid: true, value: "perl" }, - description: "Perl programming language", - icon: "", - }, - { - name: "R", - value: { valid: true, value: "r" }, - description: "R programming language", - icon: "", - }, - { - name: "MATLAB", - value: { valid: true, value: "matlab" }, - description: "MATLAB programming language", - icon: "", + description: "PHP 8.4", + icon: "/icon/php.svg", }, { - name: "Julia", - value: { valid: true, value: "julia" }, - description: "Julia programming language", - icon: "", - }, - { - name: "Dart", - value: { valid: true, value: "dart" }, - description: "Dart programming language", - icon: "", + name: "Python", + value: { valid: true, value: "python" }, + description: "Python 3.13 and uv", + icon: "/icon/python.svg", }, { - name: "Lua", - value: { valid: true, value: "lua" }, - description: "Lua programming language", - icon: "", + name: "Swift", + value: { valid: true, value: "swift" }, + description: "Swift 6.1", + icon: "/icon/swift.svg", }, { - name: "Haskell", - value: { valid: true, value: "haskell" }, - description: "Haskell programming language", - icon: "", + name: "Ruby", + value: { valid: true, value: "ruby" }, + description: "Ruby 3.4, bundle, rufo", + icon: "/icon/ruby.png", }, ], styling: { diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 3052bfdb14b42..b88157c458368 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -17,12 +17,12 @@ import { } from "components/MultiSelectCombobox/MultiSelectCombobox"; import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; import { - SearchableSelect, - SearchableSelectContent, - SearchableSelectItem, - SearchableSelectTrigger, - SearchableSelectValue, -} from "components/SearchableSelect/SearchableSelect"; + 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"; @@ -50,6 +50,7 @@ import { type FC, useEffect, useId, useRef, useState } from "react"; import { cn } from "utils/cn"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; +import { Combobox } from "components/Combobox/Combobox"; interface DynamicParameterProps { parameter: PreviewParameter; @@ -83,7 +84,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -434,44 +435,56 @@ 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 ( - - - - - - {parameter.options.map((option, index) => { - const optionValue = - option.value.value === "" - ? EMPTY_VALUE_PLACEHOLDER - : option.value.value; - return ( - - - - ); - })} - - - ); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + return onChange(value)} + options={parameter.options.map(( + option, + ) => ({ icon: option.icon, displayName: option.name, value: option.value.value }))} /> } + // 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 ( + // + // ); + // } case "multi-select": { const parsedValues = parseStringArrayValue(value ?? ""); @@ -691,11 +704,10 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From 557f8f7e2462f7ca22485a6439c92e5c34cf7530 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 9 Jul 2025 22:59:50 +0000 Subject: [PATCH 07/21] oh yeah --- .../components/Combobox/Combobox.stories.tsx | 33 ++++++++- site/src/components/Combobox/Combobox.tsx | 28 +++++-- .../DynamicParameter.stories.tsx | 9 ++- .../DynamicParameter/DynamicParameter.tsx | 74 +++++-------------- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 2786f35b0bf5e..93b5ef61d88d4 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -3,9 +3,34 @@ 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", + 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(""); @@ -43,6 +68,10 @@ export const Default: Story = { render: () => , }; +export const SimpleOptions: Story = { + render: () => , +}; + export const OpenCombobox: Story = { render: () => , play: async ({ canvasElement }) => { diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index 7390f867a0204..192462f033e6e 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, @@ -34,10 +35,6 @@ type ComboboxOption = { value: string; }; -function normalizeOptions(options: Readonly>): readonly ComboboxOption[] { -return options.map((option) => typeof options === "string" ? ({ displayName: option, value: option }) : option); -} - export const Combobox: FC = ({ value, options = [], @@ -49,6 +46,18 @@ export const Combobox: FC = ({ onKeyDown, onSelect, }) => { + const optionsMap = new Map(); + for (const option of options) { + if (typeof option === "string") { + optionsMap.set(option, { displayName: option, value: option }); + continue; + } + + optionsMap.set(option.value, option); + } + const optionObjects = [...optionsMap.values()]; + const showIcons = optionObjects.some((it) => it.icon); + return ( @@ -58,7 +67,7 @@ export const Combobox: FC = ({ className="w-72 justify-between group" > - {value || placeholder} + {optionsMap.get(value)?.displayName || placeholder} @@ -80,7 +89,7 @@ export const Combobox: FC = ({ - {normalizeOptions(options).map((option) => ( + {optionObjects.map((option) => ( = ({ onSelect(currentValue === value ? "" : currentValue); }} > + {showIcons && ( + + )} {option.displayName} {value === option.value && ( diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index 89d7078afa15f..bfbecce6cf238 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -10,7 +10,7 @@ const meta: Meta = { }, args: { parameter: MockPreviewParameter, - onChange: () => { }, + onChange: () => {}, }, }; @@ -61,6 +61,12 @@ export const Dropdown: Story = { description: "Go 1.24, gofumpt, golangci-lint", icon: "/icon/go.svg", }, + { + name: "Gleam", + value: { valid: true, value: "gleam" }, + description: "Gleam 1.11, Erlang 28, rebar3", + icon: "https://github.com/gleam-lang.png", + }, { name: "Kotlin/Java", value: { valid: true, value: "jvm" }, @@ -108,7 +114,6 @@ export const Dropdown: Story = { name: "PHP", value: { valid: true, value: "php" }, description: "PHP 8.4", - icon: "/icon/php.svg", }, { name: "Python", diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index b88157c458368..e3353b5ea8d9f 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"; @@ -50,7 +44,6 @@ import { type FC, useEffect, useId, useRef, useState } from "react"; import { cn } from "utils/cn"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; -import { Combobox } from "components/Combobox/Combobox"; interface DynamicParameterProps { parameter: PreviewParameter; @@ -438,53 +431,22 @@ const ParameterField: FC = ({ const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); - return onChange(value)} - options={parameter.options.map(( - option, - ) => ({ icon: option.icon, displayName: option.name, value: option.value.value }))} /> + return ( + onChange(value)} + options={parameter.options.map((option) => ({ + icon: option.icon, + displayName: option.name, + value: option.value.value, + }))} + /> + ); } - // 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 ( - // - // ); - // } case "multi-select": { const parsedValues = parseStringArrayValue(value ?? ""); @@ -705,8 +667,8 @@ const ParameterDiagnostics: FC = ({

{diagnostic.summary}

From 4f87578afe9db36b5b362e50ce42ed6d3d92d019 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 9 Jul 2025 23:00:17 +0000 Subject: [PATCH 08/21] :| --- .../workspaces/DynamicParameter/DynamicParameter.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index e3353b5ea8d9f..ca987e89139f3 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -77,7 +77,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -666,10 +666,11 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From 05b0ab5d734a20dd066d99408b95e88e4a67f3ac Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 10 Jul 2025 21:19:25 +0000 Subject: [PATCH 09/21] yay --- site/package.json | 2 +- .../MultiSelectCombobox.tsx | 2 +- .../DynamicParameter.stories.tsx | 95 ++++++------------- .../DynamicParameter/DynamicParameter.tsx | 2 +- 4 files changed, 31 insertions(+), 70 deletions(-) diff --git a/site/package.json b/site/package.json index 1512a803b0a96..5f34c78e83532 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/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 bfbecce6cf238..5e077df642855 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -56,86 +56,47 @@ export const Dropdown: Story = { type: "string", options: [ { - name: "Go", - value: { valid: true, value: "go" }, - description: "Go 1.24, gofumpt, golangci-lint", - icon: "/icon/go.svg", + 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: "Gleam", - value: { valid: true, value: "gleam" }, - description: "Gleam 1.11, Erlang 28, rebar3", - icon: "https://github.com/gleam-lang.png", + 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: "Kotlin/Java", - value: { valid: true, value: "jvm" }, - description: "OpenJDK 24 and Gradle", - icon: "/icon/kotlin.svg", + name: "Ajani, Nacatl Pariah", + value: { valid: true, value: "ajani" }, + description: "His pride denied him; his brother did not.", + icon: "/emojis/26aa.png", }, { - name: "Rust", - value: { valid: true, value: "rust" }, - description: "rustup w/ stable and nightly toolchains", - icon: "/icon/rust.svg", + name: "Glowing Anemone", + value: { valid: true, value: "anemone" }, + description: "Beautiful to behold, terrible to be held.", + icon: "/emojis/1f535.png", }, { - name: "TypeScript/JavaScript", - value: { valid: true, value: "js" }, - description: "Node.js 24, fnm, and npm/yarn/pnpm via corepack", - icon: "/icon/typescript.svg", - }, - - { - name: "C++", - value: { valid: true, value: "cpp" }, - description: "gcc 15.1, CMake, ninja, autotools, vcpkg, clang-format", - icon: "/icon/cpp.svg", - }, - { - name: "C#", - value: { valid: true, value: "csharp" }, - description: ".NET 9", - icon: "/icon/dotnet.svg", - }, - { - name: "Dart", - value: { valid: true, value: "dart" }, - description: "Dart 3", - icon: "https://github.com/dart-lang.png", - }, - { - name: "Julia", - value: { valid: true, value: "julia" }, - description: "Julia 1.10", - icon: "https://github.com/JuliaLang.png", - }, - { - name: "PHP", - value: { valid: true, value: "php" }, - description: "PHP 8.4", - }, - { - name: "Python", - value: { valid: true, value: "python" }, - description: "Python 3.13 and uv", - icon: "/icon/python.svg", - }, - { - name: "Swift", - value: { valid: true, value: "swift" }, - description: "Swift 6.1", - icon: "/icon/swift.svg", + name: "Springmantle Cleric", + value: { valid: true, value: "cleric" }, + description: "Hope and courage bloom in her wake.", + icon: "/emojis/1f7e2.png", }, { - name: "Ruby", - value: { valid: true, value: "ruby" }, - description: "Ruby 3.4, bundle, rufo", - icon: "/icon/ruby.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 programming language", + placeholder: "Select a creature", }, }, }, diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index ca987e89139f3..771dea71ba23b 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -433,7 +433,7 @@ const ParameterField: FC = ({ return ( Date: Thu, 10 Jul 2025 22:26:21 +0000 Subject: [PATCH 10/21] allow combobox to manage itself --- .../components/Combobox/Combobox.stories.tsx | 15 ++++++------ site/src/components/Combobox/Combobox.tsx | 24 ++++++++++++------- .../DynamicParameter/DynamicParameter.tsx | 7 ------ 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 93b5ef61d88d4..af5db8c15a5bd 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -59,21 +59,25 @@ const ComboboxWithHooks = ({ const meta: Meta = { title: "components/Combobox", component: Combobox, + args: { options: advancedOptions }, }; export default meta; type Story = StoryObj; -export const Default: Story = { +export const Default: Story = {}; + +export const ExternallyManaged: Story = { render: () => , }; export const SimpleOptions: Story = { - render: () => , + args: { + options: simpleOptions, + }, }; export const OpenCombobox: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -83,7 +87,6 @@ export const OpenCombobox: Story = { }; export const SelectOption: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -96,7 +99,6 @@ export const SelectOption: Story = { }; export const SearchAndFilter: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -117,7 +119,6 @@ export const SearchAndFilter: Story = { }; export const EnterCustomValue: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -130,7 +131,6 @@ export const EnterCustomValue: Story = { }; export const NoResults: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -144,7 +144,6 @@ export const NoResults: Story = { }; export const ClearSelectedOption: Story = { - render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index 192462f033e6e..115c2d8ba4bac 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -14,17 +14,17 @@ import { PopoverTrigger, } from "components/Popover/Popover"; import { Check, ChevronDown, CornerDownLeft } from "lucide-react"; -import type { FC, KeyboardEventHandler } from "react"; +import { type FC, type KeyboardEventHandler, useState } from "react"; import { cn } from "utils/cn"; interface ComboboxProps { value: 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; } @@ -46,6 +46,9 @@ export const Combobox: FC = ({ onKeyDown, onSelect, }) => { + const [managedOpen, setManagedOpen] = useState(false); + const [managedInputValue, setManagedInputValue] = useState(""); + const optionsMap = new Map(); for (const option of options) { if (typeof option === "string") { @@ -59,11 +62,14 @@ export const Combobox: FC = ({ const showIcons = optionObjects.some((it) => it.icon); return ( - + From d34ffc056ed143f110f00592e5f8d3c01b45822c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 10 Jul 2025 23:20:37 +0000 Subject: [PATCH 14/21] ugh --- site/src/components/Combobox/Combobox.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 1a9fc8ef7990d..84007b83f87af 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -149,10 +149,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: "Go" })); + await userEvent.click(goOption); // Then clear it by selecting it again - await userEvent.click(screen.getByRole("option", { name: "Go" })); + await userEvent.click(goOption); await waitFor(() => expect(canvas.getByRole("button")).toHaveTextContent("Select option"), From cb908219ca514291d00af4fc8ccab90dfda71cbd Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 10 Jul 2025 23:24:47 +0000 Subject: [PATCH 15/21] ugh --- site/src/components/Combobox/Combobox.stories.tsx | 9 ++------- site/src/components/Combobox/Combobox.tsx | 7 +++---- .../workspaces/DynamicParameter/DynamicParameter.tsx | 11 ++++++----- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 84007b83f87af..07dfb1b31ec89 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -103,17 +103,12 @@ export const SearchAndFilter: Story = { await userEvent.click(canvas.getByRole("button")); await userEvent.type(screen.getByRole("combobox"), "r"); await waitFor(() => { - expect( - screen.getByRole("option", { name: "Rust" }), - ).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Rust" })).toBeInTheDocument(); expect( screen.queryByRole("option", { name: "Kotlin" }), ).not.toBeInTheDocument(); }); - await userEvent.click( - screen.getByRole("option", { name: "Rust" }), - ); - + await userEvent.click(screen.getByRole("option", { name: "Rust" })); }, }; diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index fb2d571f8cb98..75a098b9dfe2f 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -20,11 +20,9 @@ import { TooltipTrigger, } from "components/Tooltip/Tooltip"; import { Check, ChevronDown, CornerDownLeft } from "lucide-react"; +import { Info } from "lucide-react"; import { type FC, type KeyboardEventHandler, useState } from "react"; import { cn } from "utils/cn"; -import { - Info, -} from "lucide-react"; interface ComboboxProps { value: string; @@ -137,7 +135,8 @@ export const Combobox: FC = ({ - )}
+ )} +
))} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 66e9b4f8ebf6d..5d92fb6d6ae6d 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -77,7 +77,7 @@ export const DynamicParameter: FC = ({ />
{parameter.form_type === "input" || - parameter.form_type === "textarea" ? ( + parameter.form_type === "textarea" ? ( = ({ className={cn( "overflow-y-auto max-h-[500px]", parameter.styling?.mask_input && - !showMaskedInput && - "[-webkit-text-security:disc]", + !showMaskedInput && + "[-webkit-text-security:disc]", )} value={localValue} onChange={(e) => { @@ -660,10 +660,11 @@ const ParameterDiagnostics: FC = ({ return (

{diagnostic.summary}

{diagnostic.detail &&

{diagnostic.detail}

} From 30d528e54e3cd3cca795b9e4319276f236e94ea9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 10 Jul 2025 23:37:20 +0000 Subject: [PATCH 16/21] fix more tests --- site/src/components/Combobox/Combobox.tsx | 1 + .../DynamicParameter.test.tsx | 22 +------------------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index 75a098b9dfe2f..d330736b1538e 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -77,6 +77,7 @@ export const Combobox: FC = ({