Skip to content

Commit f6b7b3c

Browse files
committed
Apply PR changes (squashed due to cherry-pick conflicts)
1 parent ce5d682 commit f6b7b3c

12 files changed

Lines changed: 954 additions & 1 deletion
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { buildApiUrl } from "@tests/utils/handlers";
3+
import { HttpResponse, http } from "msw";
4+
import { useState } from "react";
5+
import { createFakeBlockDocument } from "@/mocks";
6+
import { reactQueryDecorator } from "@/storybook/utils";
7+
import { BlockDocumentCombobox } from "./block-document-combobox";
8+
9+
const MOCK_BLOCK_DOCUMENTS_DATA = Array.from({ length: 5 }, (_, i) =>
10+
createFakeBlockDocument({ name: `my-block-${i}` }),
11+
);
12+
13+
const meta = {
14+
title: "Components/Blocks/BlockDocumentCombobox",
15+
render: (args) => <BlockDocumentComboboxStory {...args} />,
16+
decorators: [reactQueryDecorator],
17+
parameters: {
18+
msw: {
19+
handlers: [
20+
http.post(buildApiUrl("/block_documents/filter"), () => {
21+
return HttpResponse.json(MOCK_BLOCK_DOCUMENTS_DATA);
22+
}),
23+
],
24+
},
25+
},
26+
args: {
27+
blockTypeSlug: "aws-credentials",
28+
},
29+
} satisfies Meta<{ blockTypeSlug: string; showCreateNew?: boolean }>;
30+
31+
export default meta;
32+
33+
type Story = StoryObj<typeof meta>;
34+
35+
export const Default: Story = { name: "BlockDocumentCombobox" };
36+
37+
export const WithCreateNew: Story = {
38+
name: "With Create New Button",
39+
args: {
40+
showCreateNew: true,
41+
},
42+
};
43+
44+
export const Empty: Story = {
45+
name: "Empty State",
46+
parameters: {
47+
msw: {
48+
handlers: [
49+
http.post(buildApiUrl("/block_documents/filter"), () => {
50+
return HttpResponse.json([]);
51+
}),
52+
],
53+
},
54+
},
55+
};
56+
57+
const BlockDocumentComboboxStory = ({
58+
blockTypeSlug,
59+
showCreateNew,
60+
}: {
61+
blockTypeSlug: string;
62+
showCreateNew?: boolean;
63+
}) => {
64+
const [selected, setSelected] = useState<string | undefined>();
65+
66+
return (
67+
<BlockDocumentCombobox
68+
blockTypeSlug={blockTypeSlug}
69+
selectedBlockDocumentId={selected}
70+
onSelect={setSelected}
71+
onCreateNew={
72+
showCreateNew ? () => alert("Create new clicked") : undefined
73+
}
74+
/>
75+
);
76+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { buildApiUrl, createWrapper, server } from "@tests/utils";
4+
import { mockPointerEvents } from "@tests/utils/browser";
5+
import { HttpResponse, http } from "msw";
6+
import { beforeAll, describe, expect, it, vi } from "vitest";
7+
import type { components } from "@/api/prefect";
8+
import { createFakeBlockDocument } from "@/mocks";
9+
import { BlockDocumentCombobox } from "./block-document-combobox";
10+
11+
describe("BlockDocumentCombobox", () => {
12+
beforeAll(mockPointerEvents);
13+
14+
const mockListBlockDocumentsAPI = (
15+
blockDocuments: Array<components["schemas"]["BlockDocument"]>,
16+
) => {
17+
server.use(
18+
http.post(buildApiUrl("/block_documents/filter"), () => {
19+
return HttpResponse.json(blockDocuments);
20+
}),
21+
);
22+
};
23+
24+
it("able to select a block document", async () => {
25+
const mockOnSelect = vi.fn();
26+
const blockDocuments = [
27+
createFakeBlockDocument({ id: "block-1", name: "my_block_0" }),
28+
createFakeBlockDocument({ id: "block-2", name: "my_block_1" }),
29+
];
30+
mockListBlockDocumentsAPI(blockDocuments);
31+
32+
const user = userEvent.setup();
33+
34+
render(
35+
<BlockDocumentCombobox
36+
blockTypeSlug="aws-credentials"
37+
selectedBlockDocumentId={undefined}
38+
onSelect={mockOnSelect}
39+
/>,
40+
{ wrapper: createWrapper() },
41+
);
42+
43+
await waitFor(() =>
44+
expect(screen.getByLabelText(/select a block/i)).toBeVisible(),
45+
);
46+
47+
await user.click(screen.getByLabelText(/select a block/i));
48+
await user.click(screen.getByRole("option", { name: "my_block_0" }));
49+
50+
expect(mockOnSelect).toHaveBeenLastCalledWith("block-1");
51+
});
52+
53+
it("has the selected value displayed", async () => {
54+
const blockDocuments = [
55+
createFakeBlockDocument({ id: "block-1", name: "my_block_0" }),
56+
createFakeBlockDocument({ id: "block-2", name: "my_block_1" }),
57+
];
58+
mockListBlockDocumentsAPI(blockDocuments);
59+
60+
render(
61+
<BlockDocumentCombobox
62+
blockTypeSlug="aws-credentials"
63+
selectedBlockDocumentId="block-1"
64+
onSelect={vi.fn()}
65+
/>,
66+
{ wrapper: createWrapper() },
67+
);
68+
69+
await waitFor(() => expect(screen.getByText("my_block_0")).toBeVisible());
70+
});
71+
72+
it("shows placeholder when no block document is selected", async () => {
73+
mockListBlockDocumentsAPI([]);
74+
75+
render(
76+
<BlockDocumentCombobox
77+
blockTypeSlug="aws-credentials"
78+
selectedBlockDocumentId={undefined}
79+
onSelect={vi.fn()}
80+
/>,
81+
{ wrapper: createWrapper() },
82+
);
83+
84+
await waitFor(() =>
85+
expect(screen.getByText("Select a block...")).toBeVisible(),
86+
);
87+
});
88+
89+
it("shows create new button when onCreateNew is provided", async () => {
90+
const mockOnCreateNew = vi.fn();
91+
mockListBlockDocumentsAPI([]);
92+
93+
const user = userEvent.setup();
94+
95+
render(
96+
<BlockDocumentCombobox
97+
blockTypeSlug="aws-credentials"
98+
selectedBlockDocumentId={undefined}
99+
onSelect={vi.fn()}
100+
onCreateNew={mockOnCreateNew}
101+
/>,
102+
{ wrapper: createWrapper() },
103+
);
104+
105+
await waitFor(() =>
106+
expect(screen.getByLabelText(/select a block/i)).toBeVisible(),
107+
);
108+
109+
await user.click(screen.getByLabelText(/select a block/i));
110+
await user.click(screen.getByRole("option", { name: /create new block/i }));
111+
112+
expect(mockOnCreateNew).toHaveBeenCalled();
113+
});
114+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useSuspenseQuery } from "@tanstack/react-query";
2+
import { Suspense, useDeferredValue, useMemo, useState } from "react";
3+
import { buildListFilterBlockDocumentsQuery } from "@/api/block-documents";
4+
import {
5+
Combobox,
6+
ComboboxCommandEmtpy,
7+
ComboboxCommandGroup,
8+
ComboboxCommandInput,
9+
ComboboxCommandItem,
10+
ComboboxCommandList,
11+
ComboboxContent,
12+
ComboboxTrigger,
13+
} from "@/components/ui/combobox";
14+
import { Icon } from "@/components/ui/icons";
15+
16+
type BlockDocumentComboboxProps = {
17+
blockTypeSlug: string;
18+
selectedBlockDocumentId: string | undefined;
19+
onSelect: (blockDocumentId: string | undefined) => void;
20+
onCreateNew?: () => void;
21+
};
22+
23+
export const BlockDocumentCombobox = ({
24+
blockTypeSlug,
25+
selectedBlockDocumentId,
26+
onSelect,
27+
onCreateNew,
28+
}: BlockDocumentComboboxProps) => {
29+
return (
30+
<Suspense>
31+
<BlockDocumentComboboxImplementation
32+
blockTypeSlug={blockTypeSlug}
33+
selectedBlockDocumentId={selectedBlockDocumentId}
34+
onSelect={onSelect}
35+
onCreateNew={onCreateNew}
36+
/>
37+
</Suspense>
38+
);
39+
};
40+
41+
const BlockDocumentComboboxImplementation = ({
42+
blockTypeSlug,
43+
selectedBlockDocumentId,
44+
onSelect,
45+
onCreateNew,
46+
}: BlockDocumentComboboxProps) => {
47+
const [search, setSearch] = useState("");
48+
const deferredSearch = useDeferredValue(search);
49+
50+
const { data } = useSuspenseQuery(
51+
buildListFilterBlockDocumentsQuery({
52+
offset: 0,
53+
sort: "BLOCK_TYPE_AND_NAME_ASC",
54+
include_secrets: false,
55+
block_types: {
56+
slug: { any_: [blockTypeSlug] },
57+
},
58+
block_documents: {
59+
operator: "and_",
60+
is_anonymous: { eq_: false },
61+
...(deferredSearch ? { name: { like_: deferredSearch } } : {}),
62+
},
63+
limit: 50,
64+
}),
65+
);
66+
67+
const filteredData = useMemo(() => {
68+
return data.filter((blockDocument) =>
69+
blockDocument.name?.toLowerCase().includes(deferredSearch.toLowerCase()),
70+
);
71+
}, [data, deferredSearch]);
72+
73+
const selectedBlockDocument = useMemo(() => {
74+
return data.find(
75+
(blockDocument) => blockDocument.id === selectedBlockDocumentId,
76+
);
77+
}, [data, selectedBlockDocumentId]);
78+
79+
return (
80+
<Combobox>
81+
<ComboboxTrigger
82+
selected={Boolean(selectedBlockDocumentId)}
83+
aria-label="Select a block"
84+
>
85+
{selectedBlockDocument?.name ?? "Select a block..."}
86+
</ComboboxTrigger>
87+
<ComboboxContent>
88+
<ComboboxCommandInput
89+
value={search}
90+
onValueChange={setSearch}
91+
placeholder="Search for a block..."
92+
/>
93+
<ComboboxCommandEmtpy>No block found</ComboboxCommandEmtpy>
94+
<ComboboxCommandList>
95+
<ComboboxCommandGroup>
96+
{filteredData.map((blockDocument) => (
97+
<ComboboxCommandItem
98+
key={blockDocument.id}
99+
selected={selectedBlockDocumentId === blockDocument.id}
100+
onSelect={(value) => {
101+
onSelect(value);
102+
setSearch("");
103+
}}
104+
value={blockDocument.id}
105+
>
106+
{blockDocument.name}
107+
</ComboboxCommandItem>
108+
))}
109+
</ComboboxCommandGroup>
110+
{onCreateNew && (
111+
<ComboboxCommandGroup>
112+
<ComboboxCommandItem
113+
onSelect={() => {
114+
onCreateNew();
115+
setSearch("");
116+
}}
117+
value="__create_new__"
118+
closeOnSelect={true}
119+
>
120+
<Icon id="Plus" className="mr-2 size-4" />
121+
Create new block
122+
</ComboboxCommandItem>
123+
</ComboboxCommandGroup>
124+
)}
125+
</ComboboxCommandList>
126+
</ComboboxContent>
127+
</Combobox>
128+
);
129+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BlockDocumentCombobox } from "./block-document-combobox";
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { buildApiUrl } from "@tests/utils/handlers";
3+
import { HttpResponse, http } from "msw";
4+
import { useState } from "react";
5+
import { Button } from "@/components/ui/button";
6+
import { createFakeBlockSchema, createFakeBlockType } from "@/mocks";
7+
import { reactQueryDecorator, toastDecorator } from "@/storybook/utils";
8+
import { BlockDocumentCreateDialog } from "./block-document-create-dialog";
9+
10+
const MOCK_BLOCK_TYPE = createFakeBlockType({
11+
id: "block-type-1",
12+
slug: "secret",
13+
name: "Secret",
14+
});
15+
16+
const MOCK_BLOCK_SCHEMA = createFakeBlockSchema();
17+
18+
const meta = {
19+
title: "Components/Blocks/BlockDocumentCreateDialog",
20+
render: (args) => <BlockDocumentCreateDialogStory {...args} />,
21+
decorators: [reactQueryDecorator, toastDecorator],
22+
parameters: {
23+
msw: {
24+
handlers: [
25+
http.get(buildApiUrl("/block_types/slug/:slug"), () => {
26+
return HttpResponse.json(MOCK_BLOCK_TYPE);
27+
}),
28+
http.post(buildApiUrl("/block_schemas/filter"), () => {
29+
return HttpResponse.json([
30+
{ ...MOCK_BLOCK_SCHEMA, block_type_id: MOCK_BLOCK_TYPE.id },
31+
]);
32+
}),
33+
http.post(buildApiUrl("/block_documents/"), () => {
34+
return HttpResponse.json({
35+
id: "new-block-document-id",
36+
name: "test-block",
37+
});
38+
}),
39+
],
40+
},
41+
},
42+
args: {
43+
blockTypeSlug: "secret",
44+
},
45+
} satisfies Meta<{ blockTypeSlug: string }>;
46+
47+
export default meta;
48+
49+
type Story = StoryObj<typeof meta>;
50+
51+
export const Default: Story = { name: "BlockDocumentCreateDialog" };
52+
53+
const BlockDocumentCreateDialogStory = ({
54+
blockTypeSlug,
55+
}: {
56+
blockTypeSlug: string;
57+
}) => {
58+
const [open, setOpen] = useState(false);
59+
60+
return (
61+
<>
62+
<Button onClick={() => setOpen(true)}>Open Dialog</Button>
63+
<BlockDocumentCreateDialog
64+
open={open}
65+
onOpenChange={setOpen}
66+
blockTypeSlug={blockTypeSlug}
67+
onCreated={(id) => alert(`Created block document: ${id}`)}
68+
/>
69+
</>
70+
);
71+
};

0 commit comments

Comments
 (0)