fix(ui): keep an outlived filter selection clearable, and defer the customer list

Two loading/empty transitions the disabled empty state got wrong.

A selection made in a range that had options survives a move to a range that
has none, and it still scopes the data below, so disabling the combobox
outright took away the only control that could clear it. Disable it only when
there is nothing selected to clear.

The customer list defaulted to an empty array while its query was in flight,
so the filter announced a range with no customers before anything had been
read. Leave it undefined until the query resolves, as the tag list now does.
This commit is contained in:
Yuneng Jiang 2026-08-27 15:49:27 -07:00
parent beed32eb60
commit ceb2fa61c4
No known key found for this signature in database
4 changed files with 42 additions and 2 deletions

View file

@ -754,6 +754,19 @@ describe("UsagePage", () => {
});
});
it("should withhold the customer list while it is still loading", async () => {
mockUseCustomers.mockReturnValue({ data: undefined, isLoading: true, error: null } as any);
renderWithProviders(<UsagePage {...defaultProps} />);
act(() => {
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } });
});
const entityUsage = await screen.findByTestId("entity-usage");
expect(entityUsage).toHaveAttribute("data-entity-list", "null");
});
it("should show agent usage view for admins", async () => {
mockUseAgents.mockReturnValue({
data: { agents: mockAgents },

View file

@ -97,7 +97,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
});
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
const { data: customers = [] } = useCustomers();
// No [] default: an unresolved query must stay undefined so the customer
// filter reads as loading rather than as a range with no customers.
const { data: customers } = useCustomers();
const { data: agentsResponse } = useAgents();
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");

View file

@ -104,6 +104,28 @@ describe("UsageExportHeader", () => {
expect(screen.queryByPlaceholderText("Select tag to filter...")).not.toBeInTheDocument();
});
it("should stay usable when a carried-over selection outlives its options", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="tag"
showFilters
filterLabel="Filter by tag"
filterPlaceholder="Select tag to filter..."
filterOptions={[]}
selectedFilters={["prod"]}
onFiltersChange={onFiltersChange}
/>,
);
expect(screen.getByPlaceholderText("No tags with usage in this range")).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Clear Filter by tag" }));
expect(onFiltersChange).toHaveBeenCalledWith([]);
});
it("should leave the filter enabled with its normal placeholder when options exist", () => {
renderWithProviders(
<UsageExportHeader

View file

@ -59,6 +59,9 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
const hasNoOptions = filterOptions.length === 0;
const emptyPlaceholder = `No ${entityType}s with usage in this range`;
// A selection carried over from a range that did have options still scopes
// the data below, so the control has to stay usable long enough to clear it.
const isFilterDisabled = hasNoOptions && selectedFilters.length === 0;
const filterList = (
<ComboboxContent anchor={anchor}>
@ -76,7 +79,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const builtInFilter = (
<Combobox
multiple
disabled={hasNoOptions}
disabled={isFilterDisabled}
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}