From 2f8ddabe92039760e45c7782517b4d450ec3ba74 Mon Sep 17 00:00:00 2001 From: Alexander Streed Date: Fri, 15 Nov 2024 16:09:53 -0600 Subject: [PATCH] [UI v2] Variables table - part 2 (#16025) --- ui-v2/src/components/ui/button/button.tsx | 2 +- ui-v2/src/components/ui/data-table.tsx | 52 +++- ui-v2/src/components/ui/input.tsx | 58 +++- ui-v2/src/components/ui/tags-input.tsx | 19 +- .../components/variables/data-table/cells.tsx | 2 +- .../variables/data-table/data-table.tsx | 106 ++++++- .../variables/data-table/search.tsx | 57 ++++ ui-v2/src/components/variables/page.tsx | 38 ++- ui-v2/src/hooks/use-debounce.ts | 19 ++ ui-v2/src/routes/variables.tsx | 166 ++++++++-- ui-v2/tests/variables/mocks.tsx | 4 + ui-v2/tests/variables/variables.test.tsx | 293 +++++++++++++++++- 12 files changed, 748 insertions(+), 68 deletions(-) create mode 100644 ui-v2/src/components/variables/data-table/search.tsx create mode 100644 ui-v2/src/hooks/use-debounce.ts diff --git a/ui-v2/src/components/ui/button/button.tsx b/ui-v2/src/components/ui/button/button.tsx index 2cf8ca1fe252..b7842f2b9d4e 100644 --- a/ui-v2/src/components/ui/button/button.tsx +++ b/ui-v2/src/components/ui/button/button.tsx @@ -1,5 +1,5 @@ import { Slot } from "@radix-ui/react-slot"; -import { type VariantProps } from "class-variance-authority"; +import type { VariantProps } from "class-variance-authority"; import * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/ui-v2/src/components/ui/data-table.tsx b/ui-v2/src/components/ui/data-table.tsx index 20e49f3b841c..2df4de0c7485 100644 --- a/ui-v2/src/components/ui/data-table.tsx +++ b/ui-v2/src/components/ui/data-table.tsx @@ -6,6 +6,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { type Table as TanstackTable, flexRender } from "@tanstack/react-table"; import { @@ -25,7 +32,7 @@ export function DataTable({ table: TanstackTable; }) { return ( - <> +
@@ -74,8 +81,39 @@ export function DataTable({
- - +
+ + +
+
+ ); +} + +interface DataTablePageSizeProps { + table: TanstackTable; +} + +function DataTablePageSize({ table }: DataTablePageSizeProps) { + return ( +
+ Items per page + +
); } @@ -88,6 +126,11 @@ export function DataTablePagination({ table, className, }: DataTablePaginationProps) { + const totalPages = table.getPageCount(); + const currentPage = Math.min( + Math.ceil(table.getState().pagination.pageIndex + 1), + totalPages, + ); return ( @@ -102,8 +145,7 @@ export function DataTablePagination({ /> - Page {Math.ceil(table.getState().pagination.pageIndex + 1)} of{" "} - {table.getPageCount()} + Page {currentPage} of {totalPages} & { className?: string; @@ -24,4 +26,56 @@ const Input = React.forwardRef( ); Input.displayName = "Input"; -export { Input, type InputProps }; +type IconInputProps = InputProps & { + Icon: React.ElementType; +}; + +const IconInput = React.forwardRef( + ({ className, Icon, ...props }, ref) => { + return ( +
+ + +
+ ); + }, +); +IconInput.displayName = "IconInput"; + +type SearchInputProps = Omit & { + debounceMs?: number; +}; + +const SearchInput = React.forwardRef( + ({ className, debounceMs = 200, onChange, value, ...props }, ref) => { + const [state, setState] = useState<{ + value: typeof value; + event?: React.ChangeEvent; + }>({ value }); + const debouncedValue = useDebounce(state.value, debounceMs); + + useEffect(() => { + if (debouncedValue && state.event) { + onChange?.(state.event); + } + }, [debouncedValue, onChange, state.event]); + + useEffect(() => { + setState({ value }); + }, [value]); + + return ( + setState({ value: e.target.value, event: e })} + {...props} + /> + ); + }, +); +SearchInput.displayName = "SearchInput"; + +export { Input, type InputProps, IconInput, SearchInput }; diff --git a/ui-v2/src/components/ui/tags-input.tsx b/ui-v2/src/components/ui/tags-input.tsx index 1e02e8d777ff..5da10254e3c3 100644 --- a/ui-v2/src/components/ui/tags-input.tsx +++ b/ui-v2/src/components/ui/tags-input.tsx @@ -8,10 +8,17 @@ import { X } from "lucide-react"; type TagsInputProps = InputProps & { value?: string[]; onChange?: (tags: string[]) => void; + placeholder?: string; }; const TagsInput = React.forwardRef( - ({ onChange, value = [], onBlur, ...props }: TagsInputProps = {}) => { + ({ + onChange, + value = [], + onBlur, + placeholder = "Enter tags", + ...props + }: TagsInputProps = {}) => { const [inputValue, setInputValue] = useState(""); const handleInputChange = (e: ChangeEvent) => { @@ -54,10 +61,10 @@ const TagsInput = React.forwardRef( }; return ( -
-
+
+
{value.map((tag, index) => ( - + {tag}
diff --git a/ui-v2/src/components/variables/data-table/cells.tsx b/ui-v2/src/components/variables/data-table/cells.tsx index b92d4450fd79..937185a86ce4 100644 --- a/ui-v2/src/components/variables/data-table/cells.tsx +++ b/ui-v2/src/components/variables/data-table/cells.tsx @@ -49,7 +49,7 @@ export const ActionsCell = ({
- diff --git a/ui-v2/src/components/variables/data-table/data-table.tsx b/ui-v2/src/components/variables/data-table/data-table.tsx index 7b8a16f23c2e..a0780f93d945 100644 --- a/ui-v2/src/components/variables/data-table/data-table.tsx +++ b/ui-v2/src/components/variables/data-table/data-table.tsx @@ -5,10 +5,22 @@ import { createColumnHelper, type PaginationState, type OnChangeFn, + type ColumnFiltersState, } from "@tanstack/react-table"; import { DataTable } from "@/components/ui/data-table"; import { Badge } from "@/components/ui/badge"; import { ActionsCell } from "./cells"; +import { useCallback } from "react"; +import { SearchInput } from "@/components/ui/input"; +import { TagsInput } from "@/components/ui/tags-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type React from "react"; const columnHelper = createColumnHelper(); @@ -66,35 +78,103 @@ const columns = [ }), ]; +type VariablesDataTableProps = { + variables: components["schemas"]["Variable"][]; + currentVariableCount: number; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + sorting: components["schemas"]["VariableSort"]; + onSortingChange: (sortKey: components["schemas"]["VariableSort"]) => void; +}; + export const VariablesDataTable = ({ variables, - totalVariableCount, + currentVariableCount, pagination, onPaginationChange, -}: { - variables: components["schemas"]["Variable"][]; - totalVariableCount: number; - pagination: PaginationState; - onPaginationChange: OnChangeFn; -}) => { + columnFilters, + onColumnFiltersChange, + sorting, + onSortingChange, +}: VariablesDataTableProps) => { + const nameSearchValue = columnFilters.find((filter) => filter.id === "name") + ?.value as string; + const tagsSearchValue = columnFilters.find((filter) => filter.id === "tags") + ?.value as string[]; + const handleNameSearchChange = useCallback( + (value?: string) => { + onColumnFiltersChange((prev) => [ + ...prev.filter((filter) => filter.id !== "name"), + { id: "name", value }, + ]); + }, + [onColumnFiltersChange], + ); + + const handleTagsSearchChange: React.ChangeEventHandler & + ((tags: string[]) => void) = useCallback( + (e: string[] | React.ChangeEvent) => { + const tags = Array.isArray(e) ? e : e.target.value; + + onColumnFiltersChange((prev) => [ + ...prev.filter((filter) => filter.id !== "tags"), + { id: "tags", value: tags }, + ]); + }, + [onColumnFiltersChange], + ); + const table = useReactTable({ data: variables, columns: columns, state: { pagination, + columnFilters, }, getCoreRowModel: getCoreRowModel(), manualPagination: true, onPaginationChange: onPaginationChange, - rowCount: totalVariableCount, + onColumnFiltersChange: onColumnFiltersChange, + rowCount: currentVariableCount, }); return ( -
-
-

- {totalVariableCount} Variables -

+
+
+
+

+ {currentVariableCount} Variables +

+
+
+ handleNameSearchChange(e.target.value)} + /> +
+
+ +
+
+ +
diff --git a/ui-v2/src/components/variables/data-table/search.tsx b/ui-v2/src/components/variables/data-table/search.tsx new file mode 100644 index 000000000000..9a611cec5168 --- /dev/null +++ b/ui-v2/src/components/variables/data-table/search.tsx @@ -0,0 +1,57 @@ +import { IconInput } from "@/components/ui/input"; +import { + Select, + SelectTrigger, + SelectValue, + SelectItem, + SelectContent, +} from "@/components/ui/select"; +import { TagsInput } from "@/components/ui/tags-input"; +import useDebounce from "@/hooks/use-debounce"; +import { SearchIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +type VariablesDataTableSearchProps = { + initialSearchValue: string; + onNameSearchChange: (value: string) => void; + onSortChange: (value: string) => void; +}; + +export const VariablesDataTableSearch = ({ + initialSearchValue, + onNameSearchChange, + onSortChange, +}: VariablesDataTableSearchProps) => { + const [searchValue, setSearchValue] = useState(initialSearchValue); + const debouncedSearchValue = useDebounce(searchValue, 500); + + useEffect(() => { + onNameSearchChange(debouncedSearchValue); + }, [debouncedSearchValue, onNameSearchChange]); + + return ( +
+ setSearchValue(e.target.value)} + /> + + +
+ ); +}; diff --git a/ui-v2/src/components/variables/page.tsx b/ui-v2/src/components/variables/page.tsx index c53603bcc0bc..cdd2cb25cb24 100644 --- a/ui-v2/src/components/variables/page.tsx +++ b/ui-v2/src/components/variables/page.tsx @@ -10,19 +10,35 @@ import { VariablesEmptyState } from "@/components/variables/empty-state"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { VariablesDataTable } from "./data-table"; -import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import type { + ColumnFiltersState, + OnChangeFn, + PaginationState, +} from "@tanstack/react-table"; + +type VariablesPageProps = { + variables: components["schemas"]["Variable"][]; + totalVariableCount: number; + currentVariableCount: number; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + sorting: components["schemas"]["VariableSort"]; + onSortingChange: (sortKey: components["schemas"]["VariableSort"]) => void; +}; export const VariablesPage = ({ variables, totalVariableCount, + currentVariableCount, pagination, onPaginationChange, -}: { - variables: components["schemas"]["Variable"][]; - totalVariableCount: number; - pagination: PaginationState; - onPaginationChange: OnChangeFn; -}) => { + columnFilters, + onColumnFiltersChange, + sorting, + onSortingChange, +}: VariablesPageProps) => { const [addVariableDialogOpen, setAddVariableDialogOpen] = useState(false); const onAddVariableClick = () => { setAddVariableDialogOpen(true); @@ -52,14 +68,18 @@ export const VariablesPage = ({
- {variables.length === 0 ? ( + {totalVariableCount === 0 ? ( ) : ( )}
diff --git a/ui-v2/src/hooks/use-debounce.ts b/ui-v2/src/hooks/use-debounce.ts new file mode 100644 index 000000000000..72227647c851 --- /dev/null +++ b/ui-v2/src/hooks/use-debounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +function useDebounce(value: T, delay = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/ui-v2/src/routes/variables.tsx b/ui-v2/src/routes/variables.tsx index 037d801d265b..6aee6a60c47c 100644 --- a/ui-v2/src/routes/variables.tsx +++ b/ui-v2/src/routes/variables.tsx @@ -4,7 +4,13 @@ import { VariablesPage } from "@/components/variables/page"; import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { zodSearchValidator } from "@tanstack/router-zod-adapter"; -import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import type { + ColumnFiltersState, + OnChangeFn, + PaginationState, +} from "@tanstack/react-table"; +import { useCallback, useMemo } from "react"; +import type { components } from "@/api/prefect"; const searchParams = z.object({ offset: z.number().int().nonnegative().optional().default(0), @@ -13,13 +19,23 @@ const searchParams = z.object({ .enum(["CREATED_DESC", "UPDATED_DESC", "NAME_ASC", "NAME_DESC"]) .optional() .default("CREATED_DESC"), + name: z.string().optional(), + tags: z.array(z.string()).optional(), }); const buildVariablesQuery = (search: z.infer) => ({ queryKey: ["variables", JSON.stringify(search)], queryFn: async () => { + const { name, tags, ...rest } = search; const response = await getQueryService().POST("/variables/filter", { - body: search, + body: { + ...rest, + variables: { + operator: "and_", + name: { like_: name }, + tags: { operator: "and_", all_: tags }, + }, + }, }); return response.data; }, @@ -27,54 +43,143 @@ const buildVariablesQuery = (search: z.infer) => ({ placeholderData: keepPreviousData, }); -const buildTotalVariableCountQuery = () => ({ - queryKey: ["total-variable-count"], - queryFn: async () => { - const response = await getQueryService().POST("/variables/count", {}); - return response.data; - }, -}); +const buildTotalVariableCountQuery = ( + search?: z.infer, +) => { + // Construct the query key so that a single request is made for each unique search value + // This is useful on initial load to avoid making duplicate calls for total and current count + const queryKey = ["total-variable-count"]; + if (search?.name) { + queryKey.push(search.name); + } + if (search?.tags && search.tags.length > 0) { + queryKey.push(JSON.stringify(search.tags)); + } + return { + queryKey, + queryFn: async () => { + const { name, tags } = search ?? {}; + if (!name && (!tags || tags.length === 0)) { + const response = await getQueryService().POST("/variables/count"); + return response.data; + } + const response = await getQueryService().POST("/variables/count", { + body: { + variables: { + operator: "and_", + name: { like_: name }, + tags: { operator: "and_", all_: tags }, + }, + }, + }); + return response.data; + }, + }; +}; function VariablesRoute() { const search = Route.useSearch(); const navigate = Route.useNavigate(); const { data: variables } = useSuspenseQuery(buildVariablesQuery(search)); + const { data: currentVariableCount } = useSuspenseQuery( + buildTotalVariableCountQuery(search), + ); const { data: totalVariableCount } = useSuspenseQuery( buildTotalVariableCountQuery(), ); const pageIndex = search.offset ? search.offset / search.limit : 0; const pageSize = search.limit ?? 10; - const pagination: PaginationState = { - pageIndex, - pageSize, - }; + const pagination: PaginationState = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize], + ); + const columnFilters: ColumnFiltersState = useMemo( + () => [ + { id: "name", value: search.name }, + { id: "tags", value: search.tags }, + ], + [search.name, search.tags], + ); - const onPaginationChange: OnChangeFn = (updater) => { - let newPagination = pagination; - if (typeof updater === "function") { - newPagination = updater(pagination); - } else { - newPagination = updater; - } - void navigate({ - to: ".", - search: (prev) => ({ - ...prev, - offset: newPagination.pageIndex * newPagination.pageSize, - limit: newPagination.pageSize, - }), - replace: true, - }); - }; + const onPaginationChange: OnChangeFn = useCallback( + (updater) => { + let newPagination = pagination; + if (typeof updater === "function") { + newPagination = updater(pagination); + } else { + newPagination = updater; + } + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + offset: newPagination.pageIndex * newPagination.pageSize, + limit: newPagination.pageSize, + }), + replace: true, + }); + }, + [pagination, navigate], + ); + + const onColumnFiltersChange: OnChangeFn = useCallback( + (updater) => { + let newColumnFilters = columnFilters; + if (typeof updater === "function") { + newColumnFilters = updater(columnFilters); + } else { + newColumnFilters = updater; + } + void navigate({ + to: ".", + search: (prev) => { + const name = newColumnFilters.find((filter) => filter.id === "name") + ?.value as string; + const tags = newColumnFilters.find((filter) => filter.id === "tags") + ?.value as string[]; + return { + ...prev, + offset: 0, + name, + tags, + }; + }, + replace: true, + }); + }, + [columnFilters, navigate], + ); + + const onSortingChange = useCallback( + (sortKey: components["schemas"]["VariableSort"]) => { + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + sort: sortKey, + }), + replace: true, + }); + }, + [navigate], + ); return ( ); } @@ -86,6 +191,7 @@ export const Route = createFileRoute("/variables")({ loader: ({ deps: search, context }) => Promise.all([ context.queryClient.ensureQueryData(buildVariablesQuery(search)), + context.queryClient.ensureQueryData(buildTotalVariableCountQuery(search)), context.queryClient.ensureQueryData(buildTotalVariableCountQuery()), ]), wrapInSuspense: true, diff --git a/ui-v2/tests/variables/mocks.tsx b/ui-v2/tests/variables/mocks.tsx index a54a69088187..bcc963380a3e 100644 --- a/ui-v2/tests/variables/mocks.tsx +++ b/ui-v2/tests/variables/mocks.tsx @@ -28,3 +28,7 @@ MockJsonInput.displayName = "MockJsonInput"; vi.mock("@/components/ui/json-input", () => ({ JsonInput: MockJsonInput, })); + +vi.mock("@/hooks/use-debounce", () => ({ + default: (v: unknown) => v, +})); diff --git a/ui-v2/tests/variables/variables.test.tsx b/ui-v2/tests/variables/variables.test.tsx index 9e3dab529586..e70eb88a9ff4 100644 --- a/ui-v2/tests/variables/variables.test.tsx +++ b/ui-v2/tests/variables/variables.test.tsx @@ -2,13 +2,25 @@ import "./mocks"; import { render, screen } from "@testing-library/react"; import { VariablesPage } from "@/components/variables/page"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { + describe, + it, + expect, + vi, + afterEach, + beforeEach, + beforeAll, +} from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { server } from "../mocks/node"; import { HttpResponse } from "msw"; import { http } from "msw"; import { queryClient } from "@/router"; +import type { + ColumnFiltersState, + PaginationState, +} from "@tanstack/react-table"; describe("Variables page", () => { it("should render with empty state", () => { @@ -20,6 +32,11 @@ describe("Variables page", () => { totalVariableCount={0} pagination={{ pageIndex: 0, pageSize: 10 }} onPaginationChange={vi.fn()} + currentVariableCount={0} + columnFilters={[]} + onColumnFiltersChange={vi.fn()} + sorting="CREATED_DESC" + onSortingChange={vi.fn()} /> , ); @@ -37,8 +54,13 @@ describe("Variables page", () => { , ); @@ -62,8 +84,13 @@ describe("Variables page", () => { , ); @@ -88,8 +115,13 @@ describe("Variables page", () => { , @@ -113,8 +145,13 @@ describe("Variables page", () => { , ); @@ -152,8 +189,13 @@ describe("Variables page", () => { , ); @@ -181,8 +223,13 @@ describe("Variables page", () => { , ); @@ -201,6 +248,26 @@ describe("Variables page", () => { }); describe("Variables table", () => { + beforeAll(() => { + // Need to mock PointerEvent for the selects to work + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } + } + window.PointerEvent = + MockPointerEvent as unknown as typeof window.PointerEvent; + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + }); const originalToLocaleString = Date.prototype.toLocaleString; // eslint-disable-line @typescript-eslint/unbound-method beforeEach(() => { // Mock toLocaleString to simulate specific timezone @@ -242,8 +309,13 @@ describe("Variables page", () => { , ); @@ -282,8 +354,13 @@ describe("Variables page", () => { , ); @@ -306,8 +383,13 @@ describe("Variables page", () => { , ); @@ -342,8 +424,13 @@ describe("Variables page", () => { , ); @@ -371,8 +458,13 @@ describe("Variables page", () => { , ); @@ -399,8 +491,13 @@ describe("Variables page", () => { , ); @@ -428,8 +525,13 @@ describe("Variables page", () => { , ); @@ -438,5 +540,194 @@ describe("Variables page", () => { await user.click(screen.getByText("Delete")); expect(screen.getByText("Variable deleted")).toBeVisible(); }); + + it("should handle filtering by name", async () => { + const user = userEvent.setup(); + const variables = [ + { + id: "1", + name: "my-variable", + value: 123, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + tags: ["tag1"], + }, + ]; + const onColumnFiltersChange = vi.fn(); + + render( + + + , + ); + + // Clear any initial calls from mounting + onColumnFiltersChange.mockClear(); + + const nameSearchInput = screen.getByPlaceholderText("Search variables"); + expect(nameSearchInput).toHaveValue("start value"); + + await user.clear(nameSearchInput); + await user.type(nameSearchInput, "my-variable"); + + const lastCallArgs = onColumnFiltersChange.mock.lastCall?.[0] as ( + prev: ColumnFiltersState, + ) => ColumnFiltersState; + + // Need to resolve the updater function to get the expected value + expect(lastCallArgs([])).toEqual([{ id: "name", value: "my-variable" }]); + }); + + it("should handle filtering by tags", async () => { + const user = userEvent.setup(); + const variables = [ + { + id: "1", + name: "my-variable", + value: 123, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + tags: ["tag1"], + }, + ]; + + const onColumnFiltersChange = vi.fn(); + + render( + + + , + ); + + // Clear any initial calls from mounting + onColumnFiltersChange.mockClear(); + + const tagsSearchInput = screen.getByPlaceholderText("Filter by tags"); + expect(await screen.findByText("tag2")).toBeVisible(); + + await user.type(tagsSearchInput, "tag1"); + await user.keyboard("{enter}"); + + const lastCallArgs = onColumnFiltersChange.mock.lastCall?.[0] as ( + prev: ColumnFiltersState, + ) => ColumnFiltersState; + + expect(lastCallArgs([])).toEqual([ + { id: "tags", value: ["tag2", "tag1"] }, + ]); + }); + + it("should handle sorting", async () => { + const user = userEvent.setup(); + const variables = [ + { + id: "1", + name: "my-variable", + value: 123, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + tags: ["tag1"], + }, + ]; + + const onSortingChange = vi.fn(); + + render( + + + , + ); + + const select = screen.getByRole("combobox", { + name: "Variable sort order", + }); + expect(screen.getByText("Created")).toBeVisible(); + + await user.click(select); + await user.click(screen.getByText("A to Z")); + expect(onSortingChange).toHaveBeenCalledWith("NAME_ASC"); + + await user.click(select); + await user.click(screen.getByText("Z to A")); + expect(onSortingChange).toHaveBeenCalledWith("NAME_DESC"); + }); + + it("should emit when updating items per page", async () => { + const user = userEvent.setup(); + const variables = [ + { + id: "1", + name: "my-variable", + value: 123, + created: "2021-01-01T00:00:00Z", + updated: "2021-01-01T00:00:00Z", + tags: ["tag1"], + }, + ]; + const onPaginationChange = vi.fn(); + + render( + + + , + ); + + const select = screen.getByRole("combobox", { + name: "Items per page", + }); + expect(screen.getByText("10")).toBeVisible(); + + await user.click(select); + await user.click(screen.getByText("25")); + + const lastCallArgs = onPaginationChange.mock.lastCall?.[0] as ( + prev: PaginationState, + ) => PaginationState; + // Need to resolve the updater function to get the expected value + expect(lastCallArgs({ pageIndex: 0, pageSize: 10 })).toEqual({ + pageIndex: 0, + pageSize: 25, + }); + }); }); });