diff --git a/src/Common/SearchableDropdown.styles.ts b/src/Common/SearchableDropdown.styles.ts new file mode 100644 index 000000000..95827112e --- /dev/null +++ b/src/Common/SearchableDropdown.styles.ts @@ -0,0 +1,78 @@ +import { IButtonStyles, IStackStyles, ITextStyles } from "@fluentui/react"; +import * as React from "react"; + +export const getDropdownButtonStyles = (disabled: boolean): IButtonStyles => ({ + root: { + width: "100%", + height: "32px", + padding: "0 28px 0 8px", + border: "1px solid #8a8886", + background: "#fff", + color: "#323130", + textAlign: "left", + cursor: disabled ? "not-allowed" : "pointer", + position: "relative", + }, + flexContainer: { + justifyContent: "flex-start", + }, + label: { + fontWeight: "normal", + fontSize: "14px", + textAlign: "left", + }, +}); + +export const buttonLabelStyles: ITextStyles = { + root: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + display: "block", + textAlign: "left", + }, +}; + +export const buttonWrapperStyles: React.CSSProperties = { + position: "relative", + width: "100%", +}; + +export const chevronStyles: React.CSSProperties = { + position: "absolute", + right: "8px", + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", + fontSize: "12px", +}; + +export const calloutContentStyles: IStackStyles = { + root: { + display: "flex", + flexDirection: "column", + }, +}; + +export const listContainerStyles: IStackStyles = { + root: { + maxHeight: "300px", + overflowY: "auto", + }, +}; + +export const getItemStyles = (isSelected: boolean): React.CSSProperties => ({ + padding: "8px 12px", + cursor: "pointer", + fontSize: "14px", + backgroundColor: isSelected ? "#e6e6e6" : "transparent", + textAlign: "left", +}); + +export const emptyMessageStyles: ITextStyles = { + root: { + padding: "8px 12px", + color: "#605e5c", + textAlign: "left", + }, +}; diff --git a/src/Common/SearchableDropdown.test.tsx b/src/Common/SearchableDropdown.test.tsx new file mode 100644 index 000000000..b0e4e61c7 --- /dev/null +++ b/src/Common/SearchableDropdown.test.tsx @@ -0,0 +1,200 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import React from "react"; +import { SearchableDropdown } from "./SearchableDropdown"; + +interface TestItem { + id: string; + name: string; +} + +describe("SearchableDropdown", () => { + const mockItems: TestItem[] = [ + { id: "1", name: "Item One" }, + { id: "2", name: "Item Two" }, + { id: "3", name: "Item Three" }, + ]; + + const defaultProps = { + label: "Test Label", + items: mockItems, + selectedItem: null as TestItem | null, + onSelect: jest.fn(), + getKey: (item: TestItem) => item.id, + getDisplayText: (item: TestItem) => item.name, + placeholder: "Select an item", + filterPlaceholder: "Filter items", + className: "test-dropdown", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with label and placeholder", () => { + render(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Select an item")).toBeInTheDocument(); + }); + + it("should display selected item", () => { + const propsWithSelection = { + ...defaultProps, + selectedItem: mockItems[0], + }; + render(); + expect(screen.getByText("Item One")).toBeInTheDocument(); + }); + + it("should show 'No items found' when items array is empty", () => { + const propsWithEmptyItems = { + ...defaultProps, + items: [] as TestItem[], + }; + render(); + expect(screen.getByText("No Test Labels Found")).toBeInTheDocument(); + }); + + it("should open dropdown when button is clicked", () => { + render(); + const button = screen.getByText("Select an item"); + fireEvent.click(button); + expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument(); + }); + + it("should filter items based on search text", () => { + render(); + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Two" } }); + + expect(screen.getByText("Item Two")).toBeInTheDocument(); + expect(screen.queryByText("Item One")).not.toBeInTheDocument(); + expect(screen.queryByText("Item Three")).not.toBeInTheDocument(); + }); + + it("should call onSelect when an item is clicked", () => { + const onSelectMock = jest.fn(); + const propsWithMock = { + ...defaultProps, + onSelect: onSelectMock, + }; + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const item = screen.getByText("Item Two"); + fireEvent.click(item); + + expect(onSelectMock).toHaveBeenCalledWith(mockItems[1]); + }); + + it("should close dropdown after selecting an item", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument(); + + const item = screen.getByText("Item One"); + fireEvent.click(item); + + expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument(); + }); + + it("should disable button when disabled prop is true", () => { + const propsWithDisabled = { + ...defaultProps, + disabled: true, + }; + render(); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); + + it("should not open dropdown when disabled", () => { + const propsWithDisabled = { + ...defaultProps, + disabled: true, + }; + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument(); + }); + + it("should show 'No items found' when search yields no results", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Nonexistent" } }); + + expect(screen.getByText("No items found")).toBeInTheDocument(); + }); + + it("should handle case-insensitive filtering", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "two" } }); + + expect(screen.getByText("Item Two")).toBeInTheDocument(); + expect(screen.queryByText("Item One")).not.toBeInTheDocument(); + }); + + it("should clear filter text when dropdown is closed and reopened", () => { + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + const searchBox = screen.getByPlaceholderText("Filter items"); + fireEvent.change(searchBox, { target: { value: "Two" } }); + + // Close dropdown by selecting an item + const item = screen.getByText("Item Two"); + fireEvent.click(item); + + // Reopen dropdown + fireEvent.click(button); + + // Filter text should be cleared + const reopenedSearchBox = screen.getByPlaceholderText("Filter items"); + expect(reopenedSearchBox).toHaveValue(""); + }); + + it("should use custom placeholder text", () => { + const propsWithCustomPlaceholder = { + ...defaultProps, + placeholder: "Choose an option", + }; + render(); + expect(screen.getByText("Choose an option")).toBeInTheDocument(); + }); + + it("should use custom filter placeholder text", () => { + const propsWithCustomFilterPlaceholder = { + ...defaultProps, + filterPlaceholder: "Search here", + }; + render(); + + const button = screen.getByText("Select an item"); + fireEvent.click(button); + + expect(screen.getByPlaceholderText("Search here")).toBeInTheDocument(); + }); +}); diff --git a/src/Common/SearchableDropdown.tsx b/src/Common/SearchableDropdown.tsx new file mode 100644 index 000000000..a23fffa08 --- /dev/null +++ b/src/Common/SearchableDropdown.tsx @@ -0,0 +1,155 @@ +import { + Callout, + DefaultButton, + DirectionalHint, + Icon, + ISearchBoxStyles, + Label, + SearchBox, + Stack, + Text, +} from "@fluentui/react"; +import * as React from "react"; +import { useMemo, useRef, useState } from "react"; +import { + buttonLabelStyles, + buttonWrapperStyles, + calloutContentStyles, + chevronStyles, + emptyMessageStyles, + getDropdownButtonStyles, + getItemStyles, + listContainerStyles, +} from "./SearchableDropdown.styles"; + +interface SearchableDropdownProps { + label: string; + items: T[]; + selectedItem: T | null; + onSelect: (item: T) => void; + getKey: (item: T) => string; + getDisplayText: (item: T) => string; + placeholder?: string; + filterPlaceholder?: string; + className?: string; + disabled?: boolean; + onDismiss?: () => void; + searchBoxStyles?: Partial; +} + +export const SearchableDropdown = ({ + label, + items, + selectedItem, + onSelect, + getKey, + getDisplayText, + placeholder = "Select an item", + filterPlaceholder = "Filter items", + className, + disabled = false, + onDismiss, + searchBoxStyles: customSearchBoxStyles, +}: SearchableDropdownProps): React.ReactElement => { + const [isOpen, setIsOpen] = useState(false); + const [filterText, setFilterText] = useState(""); + const buttonRef = useRef(null); + + const closeDropdown = () => { + setIsOpen(false); + setFilterText(""); + }; + + const filteredItems = useMemo( + () => items?.filter((item) => getDisplayText(item).toLowerCase().includes(filterText.toLowerCase())), + [items, filterText, getDisplayText], + ); + + const handleDismiss = () => { + closeDropdown(); + onDismiss?.(); + }; + + const handleButtonClick = () => { + if (disabled) { + return; + } + + setIsOpen(!isOpen); + }; + + const handleSelect = (item: T) => { + onSelect(item); + closeDropdown(); + }; + + const buttonLabel = selectedItem + ? getDisplayText(selectedItem) + : items?.length === 0 + ? `No ${label}s Found` + : placeholder; + + const buttonId = `${className}-button`; + const buttonStyles = getDropdownButtonStyles(disabled); + + return ( + + +
+ + {buttonLabel} + + +
+ {isOpen && ( + + + setFilterText(newValue || "")} + styles={customSearchBoxStyles} + showIcon={true} + /> + + {filteredItems && filteredItems.length > 0 ? ( + filteredItems.map((item) => { + const key = getKey(item); + const isSelected = selectedItem ? getKey(selectedItem) === key : false; + return ( +
handleSelect(item)} + style={getItemStyles(isSelected)} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f3f2f1")} + onMouseLeave={(e) => + (e.currentTarget.style.backgroundColor = isSelected ? "#e6e6e6" : "transparent") + } + > + {getDisplayText(item)} +
+ ); + }) + ) : ( + No items found + )} +
+
+
+ )} +
+ ); +}; diff --git a/src/Platform/Hosted/Components/AccountSwitcher.test.tsx b/src/Platform/Hosted/Components/AccountSwitcher.test.tsx index 51e3c47f2..6de8e1571 100644 --- a/src/Platform/Hosted/Components/AccountSwitcher.test.tsx +++ b/src/Platform/Hosted/Components/AccountSwitcher.test.tsx @@ -1,12 +1,12 @@ jest.mock("../../../hooks/useSubscriptions"); jest.mock("../../../hooks/useDatabaseAccounts"); -import React from "react"; -import { render, fireEvent, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { AccountSwitcher } from "./AccountSwitcher"; -import { useSubscriptions } from "../../../hooks/useSubscriptions"; -import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; import { DatabaseAccount, Subscription } from "../../../Contracts/DataModels"; +import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts"; +import { useSubscriptions } from "../../../hooks/useSubscriptions"; +import { AccountSwitcher } from "./AccountSwitcher"; it("calls setAccount from parent component", () => { const armToken = "fakeToken"; @@ -25,7 +25,7 @@ it("calls setAccount from parent component", () => { expect(screen.getByLabelText("Subscription")).toHaveTextContent("Select a Subscription"); fireEvent.click(screen.getByText("Select a Subscription")); fireEvent.click(screen.getByText(subscriptions[0].displayName)); - expect(screen.getByLabelText("Cosmos DB Account Name")).toHaveTextContent("Select an Account"); + expect(screen.getByLabelText("Cosmos DB Account")).toHaveTextContent("Select an Account"); fireEvent.click(screen.getByText("Select an Account")); fireEvent.click(screen.getByText(accounts[0].name)); expect(setDatabaseAccount).toHaveBeenCalledWith(accounts[0]); diff --git a/src/Platform/Hosted/Components/SwitchAccount.tsx b/src/Platform/Hosted/Components/SwitchAccount.tsx index ef195ee07..72fb6b46a 100644 --- a/src/Platform/Hosted/Components/SwitchAccount.tsx +++ b/src/Platform/Hosted/Components/SwitchAccount.tsx @@ -1,6 +1,6 @@ -import { Dropdown } from "@fluentui/react"; import * as React from "react"; import { FunctionComponent } from "react"; +import { SearchableDropdown } from "../../../Common/SearchableDropdown"; import { DatabaseAccount } from "../../../Contracts/DataModels"; interface Props { @@ -17,23 +17,18 @@ export const SwitchAccount: FunctionComponent = ({ dismissMenu, }: Props) => { return ( - + label="Cosmos DB Account" + items={accounts} + selectedItem={selectedAccount} + onSelect={(account) => setSelectedAccountName(account.name)} + getKey={(account) => account.name} + getDisplayText={(account) => account.name} + placeholder="Select an Account" + filterPlaceholder="Search by Account name" className="accountSwitchAccountDropdown" - options={accounts?.map((account) => ({ - key: account.name, - text: account.name, - data: account, - }))} - onChange={(_, option) => { - setSelectedAccountName(String(option?.key)); - dismissMenu(); - }} - defaultSelectedKey={selectedAccount?.name} - placeholder={accounts && accounts.length === 0 ? "No Accounts Found" : "Select an Account"} - styles={{ - callout: "accountSwitchAccountDropdownMenu", - }} + disabled={!accounts || accounts.length === 0} + onDismiss={dismissMenu} /> ); }; diff --git a/src/Platform/Hosted/Components/SwitchSubscription.tsx b/src/Platform/Hosted/Components/SwitchSubscription.tsx index c784c5f5b..6b3bae932 100644 --- a/src/Platform/Hosted/Components/SwitchSubscription.tsx +++ b/src/Platform/Hosted/Components/SwitchSubscription.tsx @@ -1,6 +1,6 @@ -import { Dropdown } from "@fluentui/react"; import * as React from "react"; import { FunctionComponent } from "react"; +import { SearchableDropdown } from "../../../Common/SearchableDropdown"; import { Subscription } from "../../../Contracts/DataModels"; interface Props { @@ -15,24 +15,16 @@ export const SwitchSubscription: FunctionComponent = ({ selectedSubscription, }: Props) => { return ( - label="Subscription" + items={subscriptions} + selectedItem={selectedSubscription} + onSelect={(sub) => setSelectedSubscriptionId(sub.subscriptionId)} + getKey={(sub) => sub.subscriptionId} + getDisplayText={(sub) => sub.displayName} + placeholder="Select a Subscription" + filterPlaceholder="Search by Subscription name" className="accountSwitchSubscriptionDropdown" - options={subscriptions?.map((sub) => { - return { - key: sub.subscriptionId, - text: sub.displayName, - data: sub, - }; - })} - onChange={(_, option) => { - setSelectedSubscriptionId(String(option?.key)); - }} - defaultSelectedKey={selectedSubscription?.subscriptionId} - placeholder={subscriptions && subscriptions.length === 0 ? "No Subscriptions Found" : "Select a Subscription"} - styles={{ - callout: "accountSwitchSubscriptionDropdownMenu", - }} /> ); }; diff --git a/test/component-fixtures/searchableDropdown/SearchableDropdownFixture.tsx b/test/component-fixtures/searchableDropdown/SearchableDropdownFixture.tsx new file mode 100644 index 000000000..4594781ec --- /dev/null +++ b/test/component-fixtures/searchableDropdown/SearchableDropdownFixture.tsx @@ -0,0 +1,112 @@ +import { initializeIcons, Stack } from "@fluentui/react"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { SearchableDropdown } from "../../../src/Common/SearchableDropdown"; + +// Initialize Fluent UI icons +initializeIcons(); + +/** + * Mock subscription data matching the Subscription interface shape. + */ +interface MockSubscription { + subscriptionId: string; + displayName: string; + state: string; +} + +/** + * Mock database account data matching the DatabaseAccount interface shape. + */ +interface MockDatabaseAccount { + id: string; + name: string; + location: string; + type: string; + kind: string; +} + +const mockSubscriptions: MockSubscription[] = [ + { subscriptionId: "sub-001", displayName: "Development Subscription", state: "Enabled" }, + { subscriptionId: "sub-002", displayName: "Production Subscription", state: "Enabled" }, + { subscriptionId: "sub-003", displayName: "Testing Subscription", state: "Enabled" }, + { subscriptionId: "sub-004", displayName: "Staging Subscription", state: "Enabled" }, + { subscriptionId: "sub-005", displayName: "QA Subscription", state: "Enabled" }, +]; + +const mockAccounts: MockDatabaseAccount[] = [ + { + id: "acc-001", + name: "cosmos-dev-westus", + location: "westus", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + }, + { + id: "acc-002", + name: "cosmos-prod-eastus", + location: "eastus", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + }, + { + id: "acc-003", + name: "cosmos-test-northeurope", + location: "northeurope", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + }, + { + id: "acc-004", + name: "cosmos-staging-westus2", + location: "westus2", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + }, +]; + +const SearchableDropdownTestFixture: React.FC = () => { + const [selectedSubscription, setSelectedSubscription] = React.useState(null); + const [selectedAccount, setSelectedAccount] = React.useState(null); + + return ( + +
+ + label="Subscription" + items={mockSubscriptions} + selectedItem={selectedSubscription} + onSelect={(sub) => setSelectedSubscription(sub)} + getKey={(sub) => sub.subscriptionId} + getDisplayText={(sub) => sub.displayName} + placeholder="Select a Subscription" + filterPlaceholder="Search by Subscription name" + className="subscriptionDropdown" + /> +
+ +
+ + label="Cosmos DB Account" + items={selectedSubscription ? mockAccounts : []} + selectedItem={selectedAccount} + onSelect={(account) => setSelectedAccount(account)} + getKey={(account) => account.id} + getDisplayText={(account) => account.name} + placeholder="Select an Account" + filterPlaceholder="Search by Account name" + className="accountDropdown" + disabled={!selectedSubscription} + /> +
+ + {/* Display selection state for test assertions */} +
+
{selectedSubscription?.displayName || ""}
+
{selectedAccount?.name || ""}
+
+
+ ); +}; + +ReactDOM.render(, document.getElementById("root")); diff --git a/test/component-fixtures/searchableDropdown/searchableDropdown.html b/test/component-fixtures/searchableDropdown/searchableDropdown.html new file mode 100644 index 000000000..e9c32c2c9 --- /dev/null +++ b/test/component-fixtures/searchableDropdown/searchableDropdown.html @@ -0,0 +1,11 @@ + + + + + + SearchableDropdown Test Fixture + + +
+ + diff --git a/test/component-fixtures/searchableDropdown/searchableDropdown.spec.ts b/test/component-fixtures/searchableDropdown/searchableDropdown.spec.ts new file mode 100644 index 000000000..fb61659db --- /dev/null +++ b/test/component-fixtures/searchableDropdown/searchableDropdown.spec.ts @@ -0,0 +1,251 @@ +import { expect, test } from "@playwright/test"; + +const FIXTURE_URL = "https://127.0.0.1:1234/searchableDropdownFixture.html"; + +test.describe("SearchableDropdown Component", () => { + test.beforeEach(async ({ page }) => { + await page.goto(FIXTURE_URL); + await page.waitForSelector("[data-test='subscription-dropdown']"); + }); + + test("renders subscription dropdown with label and placeholder", async ({ page }) => { + await expect(page.getByText("Subscription", { exact: true })).toBeVisible(); + await expect(page.getByText("Select a Subscription")).toBeVisible(); + }); + + test("renders account dropdown as disabled when no subscription is selected", async ({ page }) => { + const accountButton = page.locator("[data-test='account-dropdown'] button"); + await expect(accountButton).toBeDisabled(); + }); + + test("opens subscription dropdown and shows all mock items", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + + await expect(page.getByText("Development Subscription")).toBeVisible(); + await expect(page.getByText("Production Subscription")).toBeVisible(); + await expect(page.getByText("Testing Subscription")).toBeVisible(); + await expect(page.getByText("Staging Subscription")).toBeVisible(); + await expect(page.getByText("QA Subscription")).toBeVisible(); + }); + + test("filters subscription items by search text", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + + const searchBox = page.getByPlaceholder("Search by Subscription name"); + await searchBox.fill("Dev"); + + await expect(page.getByText("Development Subscription")).toBeVisible(); + await expect(page.getByText("Production Subscription")).not.toBeVisible(); + await expect(page.getByText("Testing Subscription")).not.toBeVisible(); + }); + + test("performs case-insensitive filtering", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + + const searchBox = page.getByPlaceholder("Search by Subscription name"); + await searchBox.fill("production"); + + await expect(page.getByText("Production Subscription")).toBeVisible(); + await expect(page.getByText("Development Subscription")).not.toBeVisible(); + }); + + test("shows 'No items found' when search yields no results", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + + const searchBox = page.getByPlaceholder("Search by Subscription name"); + await searchBox.fill("NonexistentSubscription"); + + await expect(page.getByText("No items found")).toBeVisible(); + }); + + test("selects a subscription and updates button text", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Development Subscription").click(); + + // Dropdown should close and show selected item + await expect( + page.locator("[data-test='subscription-dropdown']").getByText("Development Subscription"), + ).toBeVisible(); + // External state should update + await expect(page.locator("[data-test='selected-subscription']")).toHaveText("Development Subscription"); + }); + + test("enables account dropdown after subscription is selected", async ({ page }) => { + // Select a subscription first + await page.getByText("Select a Subscription").click(); + await page.getByText("Production Subscription").click(); + + // Account dropdown should now be enabled + const accountButton = page.locator("[data-test='account-dropdown'] button"); + await expect(accountButton).toBeEnabled(); + }); + + test("shows account items after subscription selection", async ({ page }) => { + // Select subscription + await page.getByText("Select a Subscription").click(); + await page.getByText("Development Subscription").click(); + + // Open account dropdown + await page.getByText("Select an Account").click(); + + await expect(page.getByText("cosmos-dev-westus")).toBeVisible(); + await expect(page.getByText("cosmos-prod-eastus")).toBeVisible(); + await expect(page.getByText("cosmos-test-northeurope")).toBeVisible(); + await expect(page.getByText("cosmos-staging-westus2")).toBeVisible(); + }); + + test("filters account items by search text", async ({ page }) => { + // Select subscription + await page.getByText("Select a Subscription").click(); + await page.getByText("Testing Subscription").click(); + + // Open account dropdown and filter + await page.getByText("Select an Account").click(); + const searchBox = page.getByPlaceholder("Search by Account name"); + await searchBox.fill("prod"); + + await expect(page.getByText("cosmos-prod-eastus")).toBeVisible(); + await expect(page.getByText("cosmos-dev-westus")).not.toBeVisible(); + }); + + test("selects an account and updates both dropdowns", async ({ page }) => { + // Select subscription + await page.getByText("Select a Subscription").click(); + await page.getByText("Staging Subscription").click(); + + // Select account + await page.getByText("Select an Account").click(); + await page.getByText("cosmos-dev-westus").click(); + + // Verify both selections + await expect(page.locator("[data-test='selected-subscription']")).toHaveText("Staging Subscription"); + await expect(page.locator("[data-test='selected-account']")).toHaveText("cosmos-dev-westus"); + }); + + test("clears search filter when dropdown is closed and reopened", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + + const searchBox = page.getByPlaceholder("Search by Subscription name"); + await searchBox.fill("Dev"); + + // Select an item to close dropdown + await page.getByText("Development Subscription").click(); + + // Reopen dropdown + await page.locator("[data-test='subscription-dropdown']").getByText("Development Subscription").click(); + + // Search box should be cleared + const reopenedSearchBox = page.getByPlaceholder("Search by Subscription name"); + await expect(reopenedSearchBox).toHaveValue(""); + + // All items should be visible again + await expect(page.getByText("Production Subscription")).toBeVisible(); + await expect(page.getByText("Testing Subscription")).toBeVisible(); + }); + + test("renders account dropdown with label and placeholder after subscription selected", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Development Subscription").click(); + + await expect(page.getByText("Cosmos DB Account")).toBeVisible(); + await expect(page.getByText("Select an Account")).toBeVisible(); + }); + + test("performs case-insensitive filtering on account dropdown", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Development Subscription").click(); + + await page.getByText("Select an Account").click(); + const searchBox = page.getByPlaceholder("Search by Account name"); + await searchBox.fill("COSMOS-TEST"); + + await expect(page.getByText("cosmos-test-northeurope")).toBeVisible(); + await expect(page.getByText("cosmos-dev-westus")).not.toBeVisible(); + await expect(page.getByText("cosmos-prod-eastus")).not.toBeVisible(); + await expect(page.getByText("cosmos-staging-westus2")).not.toBeVisible(); + }); + + test("shows 'No items found' in account dropdown when search yields no results", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Production Subscription").click(); + + await page.getByText("Select an Account").click(); + const searchBox = page.getByPlaceholder("Search by Account name"); + await searchBox.fill("nonexistent-account"); + + await expect(page.getByText("No items found")).toBeVisible(); + }); + + test("clears account search filter when dropdown is closed and reopened", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Testing Subscription").click(); + + // Open account dropdown and filter + await page.getByText("Select an Account").click(); + const searchBox = page.getByPlaceholder("Search by Account name"); + await searchBox.fill("prod"); + + // Select an item to close + await page.getByText("cosmos-prod-eastus").click(); + + // Reopen and verify filter is cleared + await page.locator("[data-test='account-dropdown']").getByText("cosmos-prod-eastus").click(); + const reopenedSearchBox = page.getByPlaceholder("Search by Account name"); + await expect(reopenedSearchBox).toHaveValue(""); + + // All items visible again + await expect(page.getByText("cosmos-dev-westus")).toBeVisible(); + await expect(page.getByText("cosmos-test-northeurope")).toBeVisible(); + await expect(page.getByText("cosmos-staging-westus2")).toBeVisible(); + }); + + test("account dropdown updates button text after selection", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("QA Subscription").click(); + + await page.getByText("Select an Account").click(); + await page.getByText("cosmos-test-northeurope").click(); + + // Button should show selected account name + await expect(page.locator("[data-test='account-dropdown']").getByText("cosmos-test-northeurope")).toBeVisible(); + await expect(page.locator("[data-test='selected-account']")).toHaveText("cosmos-test-northeurope"); + }); + + test("account dropdown shows all 4 mock accounts", async ({ page }) => { + await page.getByText("Select a Subscription").click(); + await page.getByText("Staging Subscription").click(); + + await page.getByText("Select an Account").click(); + + await expect(page.getByText("cosmos-dev-westus")).toBeVisible(); + await expect(page.getByText("cosmos-prod-eastus")).toBeVisible(); + await expect(page.getByText("cosmos-test-northeurope")).toBeVisible(); + await expect(page.getByText("cosmos-staging-westus2")).toBeVisible(); + }); + + test("shows 'No Cosmos DB Accounts Found' when account list is empty (no subscription selected)", async ({ + page, + }) => { + // The account dropdown shows "No Cosmos DB Accounts Found" when disabled with no items + const accountButtonText = page.locator("[data-test='account-dropdown'] button"); + await expect(accountButtonText).toHaveText("No Cosmos DB Accounts Found"); + }); + + test("full flow: select subscription, filter accounts, select account", async ({ page }) => { + // Step 1: Select a subscription + await page.getByText("Select a Subscription").click(); + const subSearchBox = page.getByPlaceholder("Search by Subscription name"); + await subSearchBox.fill("QA"); + await page.getByText("QA Subscription").click(); + + // Step 2: Open account dropdown and filter + await page.getByText("Select an Account").click(); + const accountSearchBox = page.getByPlaceholder("Search by Account name"); + await accountSearchBox.fill("staging"); + await page.getByText("cosmos-staging-westus2").click(); + + // Step 3: Verify final state + await expect(page.locator("[data-test='selected-subscription']")).toHaveText("QA Subscription"); + await expect(page.locator("[data-test='selected-account']")).toHaveText("cosmos-staging-westus2"); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 7dcc89828..2b00b9f2b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -117,6 +117,9 @@ module.exports = function (_env = {}, argv = {}) { selfServe: "./src/SelfServe/SelfServe.tsx", connectToGitHub: "./src/GitHub/GitHubConnector.ts", ...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }), + ...(mode !== "production" && { + searchableDropdownFixture: "./test/component-fixtures/searchableDropdown/SearchableDropdownFixture.tsx", + }), }; const htmlWebpackPlugins = [ @@ -172,6 +175,11 @@ module.exports = function (_env = {}, argv = {}) { template: "test/testExplorer/testExplorer.html", chunks: ["testExplorer"], }), + new HtmlWebpackPlugin({ + filename: "searchableDropdownFixture.html", + template: "test/component-fixtures/searchableDropdown/searchableDropdown.html", + chunks: ["searchableDropdownFixture"], + }), ] : []), ];