feat(site/src/pages/AISettingsPage/SpendPage): compose the spend page view (#29629)

Adds `SpendPageView`, the presentational page: paywall and not-enabled
states, the organizations loading, error, and empty states, a warning
when the requested organization is unavailable, and the filters row
(`SpendFilters`: organization picker hidden with a single organization,
optional provider, model, and client menus, and the date range picker or
a non-interactive **Current budget period** label when no explicit range
is applied).

## Why

Keeping the view stateless lets its stories cover every page-level state
without network mocks; the container PR above wires the data.

## What to scrutinize

- `SpendFilters` passes `MaxAISpendPeriodDays` and `minDate` to the
picker. This layer shows a skeleton while the report is loading; #29631
separates the initial retention-bound load from subsequent report loads
so the picker stays mounted. The container decides when `dateRange` is
undefined.
- `SpendPageView.stories.tsx` holds only page-level states; table states
moved to the table PR below.

## Validation

`pnpm test:storybook` on `SpendPageView.stories.tsx`, repository
pre-commit.

## Delivery record

Published head `c462e135906` passed per-layer integration checks and
commit hooks. The composed head `d899228f798` passed format, check, full
frontend lint, 125 unit tests, and 86 story tests. Pre-push hooks were
skipped as requested. See #28964 for audit findings, their disposition,
and historical UAT limits. No exact-head live UAT or new independent
re-review is claimed.

## Stack context

PR 5 of 9. The stack adds a **User spend** page under **Admin settings**
> **AI** > **User spend**: total and per-user AI Gateway spend for one
organization over a reporting period, read from `GET
/api/v2/organizations/{organization}/ai/spend/users` (#29129, merged).
It is #28964 split into one-concern PRs. Historical UAT covers earlier
heads only; live UAT has not been rerun after these review fixes. Bottom
to top: DateRangePicker limits, shared dimension badge, API client
method, spend table, page view, page container, reporting period,
dimension filters, route and navigation (#28964).

> Xum prepared this PR on behalf of @ibetitsmike.
This commit is contained in:
Michael Suchacz
2026-09-22 13:02:57 +00:00
committed by GitHub
parent 6d22b8a186
commit e101ceb4f5
4 changed files with 450 additions and 0 deletions
@@ -0,0 +1,133 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn, userEvent, within } from "storybook/test";
import { MockMenu } from "#/components/Filter/storyHelpers";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "#/components/PaginationWidget/PaginationContainer.mocks";
import {
MockOrganization,
MockOrganization2,
MockOrganizationAISpendReport,
} from "#/testHelpers/entities";
import type { SpendReportQuery } from "./components/SpendUsersTable";
import { SpendPageView } from "./SpendPageView";
const mockReportQuery = {
...mockSuccessResult,
totalRecords: MockOrganizationAISpendReport.count,
data: MockOrganizationAISpendReport,
isLoading: false,
isFetching: false,
error: null,
refetch: fn(),
} satisfies SpendReportQuery;
const mockPendingReportQuery = {
...mockInitialRenderResult,
data: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: fn(),
} satisfies SpendReportQuery;
const meta = {
title: "pages/AISettingsPage/SpendPage/SpendPageView",
component: SpendPageView,
args: {
isEntitled: true,
isEnabled: true,
now: new Date("2026-03-12T12:00:00Z"),
organizations: [MockOrganization, MockOrganization2],
organization: MockOrganization,
onOrganizationChange: fn(),
isOrganizationsLoading: false,
organizationsError: null,
dateRange: {
startDate: new Date("2026-02-10T00:00:00Z"),
endDate: new Date("2026-03-12T00:00:00Z"),
},
minDate: new Date("2026-01-12T00:00:00Z"),
onDateRangeChange: fn(),
filterMenus: { provider: MockMenu, client: MockMenu, model: MockMenu },
reportQuery: mockReportQuery,
},
} satisfies Meta<typeof SpendPageView>;
export default meta;
type Story = StoryObj<typeof SpendPageView>;
export const Paywall: Story = {
args: { isEntitled: false },
};
export const NotEnabled: Story = {
args: { isEnabled: false },
};
export const OrganizationsLoading: Story = {
args: { isOrganizationsLoading: true },
};
export const OrganizationsError: Story = {
args: {
organizations: [],
organization: undefined,
organizationsError: new Error("Unable to load organizations"),
},
};
export const OrganizationsRefetchError: Story = {
args: { organizationsError: new Error("Unable to refresh organizations") },
};
export const NoPermittedOrganizations: Story = {
args: { organizations: [], organization: undefined },
};
export const Loading: Story = {
args: {
dateRange: undefined,
reportQuery: mockPendingReportQuery,
},
};
export const Users: Story = {};
export const WithoutDimensionFilters: Story = {
args: { filterMenus: undefined },
};
export const SingleOrganization: Story = {
args: { organizations: [MockOrganization] },
};
export const OrganizationMenu: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", {
name: `Organization ${MockOrganization.display_name}`,
}),
);
},
};
export const RequestedOrganizationDenied: Story = {
args: { organization: undefined },
};
export const Mobile: Story = {
globals: { viewport: { value: "mobile2", isRotated: false } },
};
// The content width a 1024px viewport leaves beside the settings sidebar.
export const NarrowContainer: Story = {
decorators: [
(Story) => (
<div className="w-[580px]">
<Story />
</div>
),
],
};
@@ -0,0 +1,69 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { mockInitialRenderResult } from "#/components/PaginationWidget/PaginationContainer.mocks";
import { MockOrganization, MockOrganization2 } from "#/testHelpers/entities";
import { render } from "#/testHelpers/renderHelpers";
import type { SpendReportQuery } from "./components/SpendUsersTable";
import { SpendPageView } from "./SpendPageView";
const pendingReportQuery = {
...mockInitialRenderResult,
data: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
} satisfies SpendReportQuery;
const renderView = (organization: typeof MockOrganization | undefined) => {
const onOrganizationChange = vi.fn();
render(
<SpendPageView
isEntitled
isEnabled
now={new Date("2026-03-12T12:00:00Z")}
organizations={[MockOrganization, MockOrganization2]}
organization={organization}
onOrganizationChange={onOrganizationChange}
isOrganizationsLoading={false}
organizationsError={null}
dateRange={undefined}
minDate={undefined}
onDateRangeChange={vi.fn()}
filterMenus={undefined}
reportQuery={pendingReportQuery}
/>,
);
return { onOrganizationChange };
};
it("reports the organization picked from the switcher", async () => {
const user = userEvent.setup();
const { onOrganizationChange } = renderView(MockOrganization);
await user.click(
screen.getByRole("button", {
name: `Organization ${MockOrganization.display_name}`,
}),
);
await user.click(
await screen.findByRole("option", { name: /My Organization 2/ }),
);
expect(onOrganizationChange).toHaveBeenCalledWith(MockOrganization2);
});
it("reports the organization picked to recover from a denied one", async () => {
const user = userEvent.setup();
const { onOrganizationChange } = renderView(undefined);
await user.click(
screen.getByRole("button", { name: /Select an organization/ }),
);
await user.click(
await screen.findByRole("option", { name: /My Organization 2/ }),
);
expect(onOrganizationChange).toHaveBeenCalledWith(MockOrganization2);
});
@@ -0,0 +1,144 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Loader } from "#/components/Loader/Loader";
import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { PremiumPaywallAIGovernance } from "#/modules/paywall/PremiumPaywallAIGovernance";
import { AIBridgeSetupAlert } from "#/pages/AIBridgePage/AIBridgeSetupAlert";
import { type SpendFilterMenus, SpendFilters } from "./components/SpendFilters";
import {
type SpendReportQuery,
SpendUsersTable,
} from "./components/SpendUsersTable";
type SpendPageViewProps = {
isEntitled: boolean;
isEnabled: boolean;
now: Date | undefined;
organizations: readonly TypesGen.Organization[];
organization: TypesGen.Organization | undefined;
onOrganizationChange: (organization: TypesGen.Organization) => void;
isOrganizationsLoading: boolean;
organizationsError: unknown;
dateRange: DateRangeValue | undefined;
minDate: Date | undefined;
onDateRangeChange: (value: DateRangeValue) => void;
filterMenus: SpendFilterMenus | undefined;
reportQuery: SpendReportQuery;
};
export const SpendPageView: FC<SpendPageViewProps> = ({
isEntitled,
isEnabled,
...contentProps
}) => {
if (!isEntitled) {
return (
<PremiumPaywallAIGovernance variant="governance" source="ai_governance" />
);
}
if (!isEnabled) {
return <AIBridgeSetupAlert />;
}
return (
<div className="flex max-w-[1100px] flex-col gap-4">
<SettingsHeader>
<SettingsHeaderTitle>User spend</SettingsHeaderTitle>
<SettingsHeaderDescription>
Monitor total and per-user AI Gateway spend for the selected
organization.
</SettingsHeaderDescription>
</SettingsHeader>
<SpendPageContent {...contentProps} />
</div>
);
};
type SpendPageContentProps = Omit<
SpendPageViewProps,
"isEntitled" | "isEnabled"
>;
const SpendPageContent: FC<SpendPageContentProps> = ({
now,
organizations,
organization,
onOrganizationChange,
isOrganizationsLoading,
organizationsError,
dateRange,
minDate,
onDateRangeChange,
filterMenus,
reportQuery,
}) => {
if (isOrganizationsLoading) {
return <Loader />;
}
if (organizations.length === 0) {
return organizationsError != null ? (
<ErrorAlert error={organizationsError} />
) : (
<EmptyState
isCompact
message="You don't have access to any organization's AI spend."
/>
);
}
const refetchErrorAlert = organizationsError != null && (
<ErrorAlert error={organizationsError} />
);
if (organization === undefined) {
return (
<>
{refetchErrorAlert}
<OrganizationAutocomplete
value={null}
options={organizations}
required
triggerClassName="w-60"
optionsTabbable
onChange={(next) => {
if (next) {
onOrganizationChange(next);
}
}}
/>
<Alert severity="warning">
This organization is unavailable, or you don't have access to it.
</Alert>
</>
);
}
return (
<>
{refetchErrorAlert}
<SpendFilters
organizations={organizations}
organization={organization}
onOrganizationChange={onOrganizationChange}
menus={filterMenus}
now={now}
dateRange={dateRange}
minDate={minDate}
isReportLoading={reportQuery.isLoading}
onDateRangeChange={onDateRangeChange}
/>
<SpendUsersTable reportQuery={reportQuery} />
</>
);
};
@@ -0,0 +1,104 @@
import { CalendarIcon } from "lucide-react";
import type { FC } from "react";
import { MaxAISpendPeriodDays, type Organization } from "#/api/typesGenerated";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import {
getOrganizationLabel,
OrganizationAutocomplete,
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
ClientFilter,
type ClientFilterMenu,
} from "#/pages/AIBridgePage/filters/ClientFilter";
import {
ModelFilter,
type ModelFilterMenu,
} from "#/pages/AIBridgePage/filters/ModelFilter";
import {
ProviderFilter,
type ProviderFilterMenu,
} from "#/pages/AIBridgePage/filters/ProviderFilter";
const FILTER_WIDTH = 150;
export type SpendFilterMenus = {
provider: ProviderFilterMenu;
model: ModelFilterMenu;
client: ClientFilterMenu;
};
type SpendFiltersProps = {
organizations: readonly Organization[];
organization: Organization;
onOrganizationChange: (organization: Organization) => void;
menus: SpendFilterMenus | undefined;
now: Date | undefined;
dateRange: DateRangeValue | undefined;
minDate: Date | undefined;
isReportLoading: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
};
export const SpendFilters: FC<SpendFiltersProps> = ({
organizations,
organization,
onOrganizationChange,
menus,
now,
dateRange,
minDate,
isReportLoading,
onDateRangeChange,
}) => {
return (
<div className="flex flex-wrap gap-2">
{organizations.length > 1 && (
<OrganizationAutocomplete
value={organization}
ariaLabel={`Organization ${getOrganizationLabel(
organization,
organizations,
)}`}
options={organizations}
triggerClassName="basis-[150px] grow"
optionsTabbable
onChange={(next) => {
if (next) {
onOrganizationChange(next);
}
}}
/>
)}
{menus && (
<>
<ProviderFilter menu={menus.provider} width={FILTER_WIDTH} />
<ModelFilter menu={menus.model} width={FILTER_WIDTH} />
<ClientFilter menu={menus.client} width={FILTER_WIDTH} />
</>
)}
{isReportLoading ? (
// The retention bound arrives with the report, so a picker shown
// before it could offer days the server rejects.
<Skeleton className="h-10 w-64" />
) : dateRange ? (
<DateRangePicker
now={now}
value={dateRange}
onChange={onDateRangeChange}
maxDays={MaxAISpendPeriodDays}
minDate={minDate}
size="lg"
/>
) : (
<div className="flex h-10 items-center gap-2 rounded-md border border-solid border-border px-3 py-2 text-sm text-content-secondary">
<CalendarIcon className="size-4" />
Current budget period
</div>
)}
</div>
);
};