Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/portal/src/components/SkillPicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { http, HttpResponse } from "msw";
import type { SkillDocument } from "@/types";
import { getSelectedProjectId, setSelectedProjectIdHolder } from "@/lib/project-scope";
import { SkillPicker } from "./SkillPicker";

const skills = Array.from({ length: 13 }, (_, index) => ({
_id: `azure/cosmos-kit/cosmos-${String(index + 1).padStart(2, "0")}`,
name: `Cosmos DB skill ${index + 1}`,
description: "Azure Cosmos DB agent guidance",
})) as SkillDocument[];

function SkillPickerHarness() {
const [selected, setSelected] = useState<string[]>([]);
const [scopeReady, setScopeReady] = useState(false);

useEffect(() => {
const previousProjectId = getSelectedProjectId();
setSelectedProjectIdHolder("demo-project");
setScopeReady(true);
return () => setSelectedProjectIdHolder(previousProjectId);
}, []);

if (!scopeReady) return null;

return (
<div className="w-[44rem] max-w-full p-6">
<SkillPicker selected={selected} onChange={setSelected} />
</div>
);
}

const meta = {
component: SkillPicker,
render: () => <SkillPickerHarness />,
args: {
selected: [],
onChange: () => {},
},
parameters: {
msw: {
handlers: [
http.get("*/api/v1/skills", () => HttpResponse.json(skills)),
http.get("*/api/v1/skills/search/external", () => HttpResponse.json([])),
http.get("*/api/v1/skills/*/revisions", () => HttpResponse.json([])),
],
},
},
} satisfies Meta<typeof SkillPicker>;

export default meta;
type Story = StoryObj<typeof meta>;

export const MultiSelectSearch: Story = {
play: async ({ canvas, canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const input = await canvas.findByPlaceholderText("Search for skills…");
await userEvent.type(input, "cosmos");
await userEvent.click(await page.findByRole("button", { name: /cosmos-01/i }));

await expect(input).toHaveValue("cosmos");
await expect(page.getByRole("button", { name: /cosmos-02/i })).toBeVisible();
},
};
132 changes: 132 additions & 0 deletions apps/portal/src/components/SkillPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// @vitest-environment happy-dom
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { SkillDocument } from "@/types";

const skills = [
{
_id: "azure/cosmos-kit/cosmos-query",
name: "Cosmos query",
description: "Query Azure Cosmos DB",
},
{
_id: "azure/cosmos-kit/cosmos-index",
name: "Cosmos index",
description: "Tune Azure Cosmos DB indexes",
},
] as SkillDocument[];

const listSkills = vi.fn(async () => skills);
const searchExternalSkills = vi.fn(async (_query: string, _limit?: number) => []);
const listSkillRevisions = vi.fn(async (_slug: string) => []);

vi.mock("@/lib/api", () => ({
api: {
listSkills: () => listSkills(),
searchExternalSkills: (query: string, limit?: number) => searchExternalSkills(query, limit),
listSkillRevisions: (slug: string) => listSkillRevisions(slug),
},
}));

import { SkillPicker } from "./SkillPicker";

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

function PickerHarness() {
const [selected, setSelected] = useState<string[]>([]);
return <SkillPicker selected={selected} onChange={setSelected} />;
}

function renderPicker() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<PickerHarness />
</QueryClientProvider>,
);
}

describe("SkillPicker", () => {
it("keeps the search and matching results open while selecting several skills", async () => {
renderPicker();
const input = await screen.findByPlaceholderText("Search for skills…");

fireEvent.change(input, { target: { value: "cosmos" } });
fireEvent.click(await screen.findByRole("button", { name: /cosmos-query/i }));

expect((input as HTMLInputElement).value).toBe("cosmos");
expect(screen.getByRole("button", { name: /cosmos-index/i })).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: /cosmos-index/i }));
expect((input as HTMLInputElement).value).toBe("cosmos");
expect(screen.getAllByText("azure/cosmos-kit/cosmos-query").length).toBeGreaterThan(1);
expect(screen.getAllByText("azure/cosmos-kit/cosmos-index").length).toBeGreaterThan(1);
});

it("opens the scrollable result list above an input near the viewport bottom", async () => {
const { container } = renderPicker();
const input = await screen.findByPlaceholderText("Search for skills…");
vi.spyOn(input, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 440,
top: 440,
right: 400,
bottom: 476,
left: 0,
width: 400,
height: 36,
toJSON: () => ({}),
});
Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });

fireEvent.focus(input);

const result = await screen.findByRole("button", { name: /cosmos-query/i });
await waitFor(() => {
const dropdown = document.querySelector("[data-skill-picker-portal]") as HTMLElement | null;
expect(dropdown?.parentElement).toBe(document.body);
expect(container.contains(dropdown)).toBe(false);
expect(dropdown?.style.position).toBe("fixed");
expect(dropdown?.style.bottom).toBe("64px");
expect(dropdown?.style.top).toBe("");
expect(result.parentElement?.style.maxHeight).toBe("256px");
});
});

it("constrains the portaled result list to the space below the input", async () => {
renderPicker();
const input = await screen.findByPlaceholderText("Search for skills…");
vi.spyOn(input, "getBoundingClientRect").mockReturnValue({
x: 20,
y: 8,
top: 8,
right: 420,
bottom: 44,
left: 20,
width: 400,
height: 36,
toJSON: () => ({}),
});
Object.defineProperty(window, "innerHeight", { configurable: true, value: 180 });

fireEvent.focus(input);

const result = await screen.findByRole("button", { name: /cosmos-query/i });
await waitFor(() => {
const dropdown = document.querySelector("[data-skill-picker-portal]") as HTMLElement | null;
expect(dropdown?.style.top).toBe("48px");
expect(dropdown?.style.bottom).toBe("");
expect(result.parentElement?.style.maxHeight).toBe("128px");
});
});
});
Loading
Loading