diff --git a/apps/portal/src/components/ui/stories.play.test.tsx b/apps/portal/src/components/ui/stories.play.test.tsx index 6ed917da2..26a8c6695 100644 --- a/apps/portal/src/components/ui/stories.play.test.tsx +++ b/apps/portal/src/components/ui/stories.play.test.tsx @@ -24,11 +24,12 @@ import * as tabs from "./tabs.stories"; import * as textarea from "./textarea.stories"; import * as tooltip from "./tooltip.stories"; import * as sonner from "./sonner.stories"; +import * as runsIterationFilter from "../../pages/RunsList.stories"; const modules: Record> = { alert, alertDialog, checkbox, dialog, dropdownMenu, label, popover, scrollArea, select, separator, sheet, skeleton, switch: sw, table, tabs, textarea, - tooltip, sonner, + tooltip, sonner, runsIterationFilter, }; afterEach(() => cleanup()); diff --git a/apps/portal/src/pages/RunsList.stories.tsx b/apps/portal/src/pages/RunsList.stories.tsx new file mode 100644 index 000000000..a5195e784 --- /dev/null +++ b/apps/portal/src/pages/RunsList.stories.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent } from "storybook/test"; +import { NumericComparatorRow } from "./RunsList"; + +function EmptyIterationFilter() { + const [op, setOp] = useState<"eq" | "gte" | "lte">("gte"); + const [value, setValue] = useState(""); + + return ( +
+ { + setOp(next.op); + setValue(next.value); + }} + /> + {`${op}:${value || "any"}`} +
+ ); +} + +const meta = { + title: "Pages/Runs/Iteration filter", + component: NumericComparatorRow, + tags: ["ai-generated", "needs-work"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ChooseComparisonBeforeValue: Story = { + args: { + label: "Turns", + ariaLabel: "Filter runs by number of turns", + op: "gte", + value: "", + onChange: () => {}, + }, + render: () => , + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("combobox", { name: "Filter runs by number of turns comparator" })); + await userEvent.click(await screen.findByRole("option", { name: "≤" })); + await expect(canvas.getByTestId("iteration-filter")).toHaveTextContent("lte:any"); + + await userEvent.type(canvas.getByRole("spinbutton", { name: "Filter runs by number of turns" }), "3"); + await expect(canvas.getByTestId("iteration-filter")).toHaveTextContent("lte:3"); + }, +}; diff --git a/apps/portal/src/pages/RunsList.tsx b/apps/portal/src/pages/RunsList.tsx index 27b685df5..5567d86a4 100644 --- a/apps/portal/src/pages/RunsList.tsx +++ b/apps/portal/src/pages/RunsList.tsx @@ -383,7 +383,7 @@ function GroupByToggle({ } // Sidebar filter widget: pick a comparator (≥ / ≤ / =) and a numeric value. -// Clearing the input clears both the value and the op from the URL. Used for +// The op is retained without a value so users can choose it before typing. Used for // the Turns and Max iterations filters; mirrors the API contract that accepts // `{turns, turnsOp}` / `{maxIterations, maxIterationsOp}` with op ∈ eq|gte|lte. const NUMERIC_OPS: ReadonlyArray<{ value: IterationOp; label: string; aria: string }> = [ @@ -392,7 +392,7 @@ const NUMERIC_OPS: ReadonlyArray<{ value: IterationOp; label: string; aria: stri { value: "eq", label: "=", aria: "equal to" }, ]; -function NumericComparatorRow({ +export function NumericComparatorRow({ label, ariaLabel, op, @@ -1981,7 +1981,7 @@ export function RunsList() { onChange={({ op, value }) => { state.setFilters({ turns: value || null, - turnsOp: value ? op : null, + turnsOp: op, }); }} /> @@ -1993,7 +1993,7 @@ export function RunsList() { onChange={({ op, value }) => { state.setFilters({ maxIter: value || null, - maxIterOp: value ? op : null, + maxIterOp: op, }); }} /> diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md index f1a243262..a2444c00f 100644 --- a/docs/architecture/app-design.md +++ b/docs/architecture/app-design.md @@ -498,6 +498,10 @@ A single value compiles to an equality clause; multiple values compile to `$in`. | `criteria` | `scenario.criteria` | | `turns` / `maxIterations` | `run.turns` size / `maxIterations` | +In the Portal's Iterations filter, the comparison operator can be selected +before a number is entered. The operator is kept in URL state while the value is +empty; the API query includes it only when the corresponding number is present. + Additional cross-cutting filters: - **`(Unknown)` sentinel** — the literal value `__empty__` (exported as `EMPTY_FILTER_VALUE`)