Skip to content

Commit

Permalink
[UI v2] feat: Adds work-pool-select component
Browse files Browse the repository at this point in the history
  • Loading branch information
devinvillarosa committed Feb 26, 2025
1 parent 6836cce commit f61eb6f
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 0 deletions.
1 change: 1 addition & 0 deletions ui-v2/src/components/work-pools/work-pool-select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WorkPoolSelect } from "./work-pool-select";
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createFakeWorkPool } from "@/mocks";
import { reactQueryDecorator } from "@/storybook/utils";
import type { Meta, StoryObj } from "@storybook/react";
import { buildApiUrl } from "@tests/utils/handlers";
import { http, HttpResponse } from "msw";

import { useState } from "react";
import { WorkPoolSelect } from "./work-pool-select";

const MOCK_WORK_POOLS_DATA = Array.from({ length: 5 }, createFakeWorkPool);
const PRESET_OPTIONS = [{ label: "None", value: undefined }];
const meta = {
title: "Components/WorkPools/WorkPoolSelect",
render: () => <WorkPoolSelectStory />,
decorators: [reactQueryDecorator],
parameters: {
msw: {
handlers: [
http.post(buildApiUrl("/work_pools/filter"), () => {
return HttpResponse.json(MOCK_WORK_POOLS_DATA);
}),
],
},
},
} satisfies Meta;

export default meta;

export const story: StoryObj = { name: "WorkPoolSelect" };

const WorkPoolSelectStory = () => {
const [selected, setSelected] = useState<string | undefined | null>();

return (
<WorkPoolSelect
selected={selected}
onSelect={setSelected}
presetOptions={PRESET_OPTIONS}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { WorkPool } from "@/api/work-pools";

import { createFakeWorkPool } from "@/mocks";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { buildApiUrl, createWrapper, server } from "@tests/utils";
import { mockPointerEvents } from "@tests/utils/browser";
import { http, HttpResponse } from "msw";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { WorkPoolSelect } from "./work-pool-select";

describe("ActionsStep", () => {
beforeAll(mockPointerEvents);

describe("WorkPoolSelect", () => {
const mockListWorkPoolsAPI = (workPools: Array<WorkPool>) => {
server.use(
http.post(buildApiUrl("/work_pools/filter"), () => {
return HttpResponse.json(workPools);
}),
);
};

it("able to select a workpool", async () => {
const mockOnSelect = vi.fn();
mockListWorkPoolsAPI([
createFakeWorkPool({ name: "my work pool 0" }),
createFakeWorkPool({ name: "my work pool 1" }),
]);

const user = userEvent.setup();

// ------------ Setup
render(<WorkPoolSelect selected={undefined} onSelect={mockOnSelect} />, {
wrapper: createWrapper(),
});

// ------------ Act
await user.click(
screen.getByRole("combobox", { name: /select a work pool/i }),
);
await user.click(screen.getByRole("option", { name: "my work pool 0" }));

// ------------ Assert
expect(mockOnSelect).toHaveBeenLastCalledWith("my work pool 0");
});

it("able to select a preset option", async () => {
const mockOnSelect = vi.fn();
mockListWorkPoolsAPI([
createFakeWorkPool({ name: "my work pool 0" }),
createFakeWorkPool({ name: "my work pool 1" }),
]);

const user = userEvent.setup();

// ------------ Setup
const PRESETS = [{ label: "None", value: undefined }];
render(
<WorkPoolSelect
presetOptions={PRESETS}
selected={undefined}
onSelect={mockOnSelect}
/>,
{ wrapper: createWrapper() },
);

// ------------ Act
await user.click(
screen.getByRole("combobox", { name: /select a work pool/i }),
);
await user.click(screen.getByRole("option", { name: "None" }));

// ------------ Assert
expect(mockOnSelect).toHaveBeenLastCalledWith(undefined);
});

it("has the selected value displayed", () => {
mockListWorkPoolsAPI([
createFakeWorkPool({ name: "my work pool 0" }),
createFakeWorkPool({ name: "my work pool 1" }),
]);

// ------------ Setup
const PRESETS = [{ label: "None", value: undefined }];
render(
<WorkPoolSelect
presetOptions={PRESETS}
selected="my work pool 0"
onSelect={vi.fn()}
/>,
{ wrapper: createWrapper() },
);

// ------------ Assert
expect(screen.getByText("my work pool 0")).toBeVisible();
});
});
});
115 changes: 115 additions & 0 deletions ui-v2/src/components/work-pools/work-pool-select/work-pool-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { buildFilterWorkPoolsQuery } from "@/api/work-pools";

import {
Combobox,
ComboboxCommandEmtpy,
ComboboxCommandGroup,
ComboboxCommandInput,
ComboboxCommandItem,
ComboboxCommandList,
ComboboxContent,
ComboboxTrigger,
} from "@/components/ui/combobox";

import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { useDeferredValue, useMemo, useState } from "react";

type PresetOption = {
label: string;
value: string | null | undefined;
};

type WorkPoolSelectProps = {
presetOptions?: Array<PresetOption>;
selected: string | undefined | null;
onSelect: (name: string | undefined | null) => void;
};

export const WorkPoolSelect = ({
presetOptions = [],
selected,
onSelect,
}: WorkPoolSelectProps) => {
const [search, setSearch] = useState("");
const { data, isSuccess } = useQuery(buildFilterWorkPoolsQuery());

// nb: because work pools API does not have filtering _like by name, do client-side filtering
const deferredSearch = useDeferredValue(search);
const filteredData = useMemo(() => {
if (!data) {
return [];
}
return data.filter((workPool) =>
workPool.name.toLowerCase().includes(deferredSearch.toLowerCase()),
);
}, [data, deferredSearch]);

const filteredPresetOptions = useMemo(() => {
return presetOptions.filter((option) =>
option.label.toLowerCase().includes(deferredSearch.toLowerCase()),
);
}, [deferredSearch, presetOptions]);

return (
<Combobox>
<ComboboxTrigger
selected={Boolean(selected)}
aria-label="Select a work pool"
>
{selected}
</ComboboxTrigger>
<ComboboxContent>
<ComboboxCommandInput
value={search}
onValueChange={setSearch}
placeholder="Search for a work pool..."
/>
<ComboboxCommandEmtpy>No work pool found</ComboboxCommandEmtpy>
<ComboboxCommandList>
<ComboboxCommandGroup>
{filteredPresetOptions.map((option) => (
<ComboboxCommandItem
key={option.label}
selected={selected === option.value}
onSelect={() => {
onSelect(option.value);
setSearch("");
}}
//nb: Stringifies label so that UX has a hoverable state
value={String(option.label)}
>
{option.label}
</ComboboxCommandItem>
))}
{isSuccess ? (
filteredData.map((workPool) => (
<ComboboxCommandItem
key={workPool.id}
selected={selected === workPool.name}
onSelect={(value) => {
onSelect(value);
setSearch("");
}}
value={workPool.name}
>
{workPool.name}
</ComboboxCommandItem>
))
) : (
<LoadingSelectState />
)}
</ComboboxCommandGroup>
</ComboboxCommandList>
</ComboboxContent>
</Combobox>
);
};

type LoadingSelectStateProps = {
length?: number;
};
const LoadingSelectState = ({ length = 4 }: LoadingSelectStateProps) =>
Array.from({ length }, (_, index) => (
<Skeleton key={index} className="mt-2 p-4 h-2 w-full" />
));

0 comments on commit f61eb6f

Please sign in to comment.