Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UI v2] feat: Adds work-pool-select component #17291

Merged
merged 1 commit into from
Feb 26, 2025
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
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();
});
});
});
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>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are preset options for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some UX where I've seen that None is an option.
And for the automations, there's an Infer option.

Adding this option just in case this component can fit the above use cases

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" />
));