From 1a51a381d7942ec577b630a2763645378f9273c2 Mon Sep 17 00:00:00 2001 From: Piotr Karpala Date: Tue, 14 Jul 2026 14:42:19 -0400 Subject: [PATCH] feat(billing): add user search box to per-user breakdown table (#434) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbf27d66-9aca-451b-85ee-3a32e0775d52 --- app/components/BillingCreditsViewer.vue | 35 ++++++++++++++++++++++--- tests/billing-per-user-search.spec.ts | 21 +++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/billing-per-user-search.spec.ts diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue index 2bdb783d..66043d96 100644 --- a/app/components/BillingCreditsViewer.vue +++ b/app/components/BillingCreditsViewer.vue @@ -280,9 +280,24 @@ Mixed sources: User list + Tokens (CLI) come from the Copilot Metrics API for {{ dateRangeDescription || 'the last 28 days' }}. Credits, Gross $, Net $, and Models come from the Billing API for {{ rangeLabel }}. + + + ()); const loadedLogins = reactive(new Set()); const perUserLoading = ref(false); + const perUserSearch = ref(''); // When the user switches month or toggles month view, drop cached // per-user roll-ups so the visible page re-fetches against the new window. @@ -576,10 +592,10 @@ export default defineComponent({ } // Called by v-data-table @update:options on initial mount, page change, - // and sort change. We use page + itemsPerPage + the currently-sorted - // `perUserRows` view to know which logins are visible. + // and sort/search change. We use page + itemsPerPage + the currently + // searchable `perUserRows` view to know which logins are visible. function onTableOptions(opts: { page: number; itemsPerPage: number }): void { - const allRows = perUserRows.value; + const allRows = filteredPerUserRowsForLazyLoad.value; if (allRows.length === 0) return; const start = (opts.page - 1) * opts.itemsPerPage; const visible = allRows.slice(start, start + opts.itemsPerPage).map(r => r.user); @@ -645,6 +661,17 @@ export default defineComponent({ }; }); }); + const filteredPerUserRowsForLazyLoad = computed(() => { + const query = perUserSearch.value.trim().toLocaleLowerCase(); + if (!query) return perUserRows.value; + return perUserRows.value.filter(row => row.user.toLocaleLowerCase().includes(query)); + }); + watch(perUserSearch, () => { + const matchedLogins = filteredPerUserRowsForLazyLoad.value + .slice(0, 50) + .map(row => row.user); + void loadBillingForLogins(matchedLogins); + }); const loadedLoginsCount = computed(() => loadedLogins.size); // True when we've loaded at least one page of users AND the aggregate @@ -830,7 +857,7 @@ export default defineComponent({ monthView, rangeLabel, totalGrossQty, totalGrossAmount, totalNetAmount, errorReason, headers, - perUserRows, perUserHeaders, + perUserRows, perUserHeaders, perUserSearch, perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution, topSpendersChartData, topSpendersChartOptions, topTokensChartData, topTokensChartOptions, diff --git a/tests/billing-per-user-search.spec.ts b/tests/billing-per-user-search.spec.ts new file mode 100644 index 00000000..12110a36 --- /dev/null +++ b/tests/billing-per-user-search.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const componentSource = readFileSync(resolve(process.cwd(), 'app/components/BillingCreditsViewer.vue'), 'utf8'); + +describe('BillingCreditsViewer per-user search', () => { + test('renders a searchable per-user table input wired to Vuetify data-table search', () => { + expect(componentSource).toContain('data-testid="billing-per-user-search"'); + expect(componentSource).toContain('v-model="perUserSearch"'); + expect(componentSource).toContain(':search="perUserSearch"'); + expect(componentSource).toContain(':filter-keys="[\'user\']"'); + }); + + test('loads billing for users matched by search outside the current page', () => { + expect(componentSource).toContain('const perUserSearch = ref'); + expect(componentSource).toContain('const filteredPerUserRowsForLazyLoad = computed'); + expect(componentSource).toContain('watch(perUserSearch'); + expect(componentSource).toContain('loadBillingForLogins(matchedLogins'); + }); +});