From 67da92a47d028a502b0bdbb91d96925739e62858 Mon Sep 17 00:00:00 2001 From: Jeff Rowell Date: Thu, 9 Jul 2026 13:51:25 -0700 Subject: [PATCH 1/2] feat: search app groups by name on the app page The app page search box previously filtered app groups only by member (a group appeared if it had an active member matching by name or email). Extend the `q` filter so it also matches a group's own name, letting users find a specific app group or a subset (e.g. all groups containing "Prd") by a case-insensitive substring without scrolling the full list. The single search box now matches group name OR member; relabel it from "Search Users" to "Search groups or users". No API schema change. --- api/routers/apps.py | 9 ++++--- src/api/infiniteAppGroups.ts | 5 ++-- src/pages/apps/Read.tsx | 4 +-- .../apps/components/AppsAdminActionGroup.tsx | 4 +-- tests/test_app.py | 26 +++++++++++++++++++ 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/api/routers/apps.py b/api/routers/apps.py index 1b6ee062..caee5c2e 100644 --- a/api/routers/apps.py +++ b/api/routers/apps.py @@ -92,9 +92,10 @@ async def get_app_groups( many members any single group has. The UI fetches a group's members on demand from `GET /api/groups/{id}/member-details`. - `owner` filters to owner / non-owner app-groups. `q` filters to groups that - have an active member matching the query by name or email — the app page's - user search, computed in SQL so it doesn't need every member client-side.""" + `owner` filters to owner / non-owner app-groups. `q` is the app page's + search: it filters to groups whose own name matches the query, or that have + an active member matching by name or email, computed in SQL so it doesn't + need every member client-side.""" resolved_app_id = ( await db.scalars( select(App.id).where(App.deleted_at.is_(None)).where(or_(App.id == app_id, App.name == app_id)) @@ -128,7 +129,7 @@ async def get_app_groups( ) .exists() ) - stmt = stmt.where(member_match) + stmt = stmt.where(or_(AppGroup.name.ilike(like), member_match)) # AppGroup.id is a unique final tiebreaker so rows that tie on # (is_owner, lower(name)) have a stable order across LIMIT/OFFSET pages. stmt = stmt.order_by(AppGroup.is_owner.desc(), func.lower(AppGroup.name), AppGroup.id) diff --git a/src/api/infiniteAppGroups.ts b/src/api/infiniteAppGroups.ts index 96fcde6d..bf3d0d55 100644 --- a/src/api/infiniteAppGroups.ts +++ b/src/api/infiniteAppGroups.ts @@ -8,8 +8,9 @@ import {deepMerge} from './apiUtils'; * Infinite-scroll variant of `useAppGroupsById`: pages an app's groups * (owners-first, 10/page) and accumulates them across pages so the app page can * load more on scroll instead of via a page-number control. `owner` filters - * owner vs non-owner groups; `q` is the server-side member search (omitted when - * empty so the fetcher doesn't serialize it as the literal "undefined"). Reuses + * owner vs non-owner groups; `q` is the server-side search by group name or + * member (omitted when empty so the fetcher doesn't serialize it as the literal + * "undefined"). Reuses * the generated fetcher + API context so auth options are injected like the * generated hooks. */ diff --git a/src/pages/apps/Read.tsx b/src/pages/apps/Read.tsx index 3d928c35..95db1601 100644 --- a/src/pages/apps/Read.tsx +++ b/src/pages/apps/Read.tsx @@ -29,8 +29,8 @@ export default function ReadApp() { }); // App groups are no longer inlined on the app payload. Owners (few) are - // fetched whole; non-owner groups load page-by-page on scroll. Member-based - // filtering is computed server-side via the `q` query param. + // fetched whole; non-owner groups load page-by-page on scroll. Filtering by + // group name or member is computed server-side via the `q` query param. const [searchQuery, setSearchQuery] = React.useState(''); const [isExpanded, setIsExpanded] = React.useState(false); diff --git a/src/pages/apps/components/AppsAdminActionGroup.tsx b/src/pages/apps/components/AppsAdminActionGroup.tsx index e98cfb4a..8fd9d1e6 100644 --- a/src/pages/apps/components/AppsAdminActionGroup.tsx +++ b/src/pages/apps/components/AppsAdminActionGroup.tsx @@ -8,7 +8,7 @@ import React from 'react'; interface AppsAdminActionGroupProps { currentUser: OktaUserDetail; app: AppDetail; - // Emits the raw user search query. Group filtering by member is computed + // Emits the raw search query. Filtering by group name or member is computed // server-side (GET /api/apps/{id}/groups?q=…) so the page no longer needs // every member loaded client-side. onSearchChange?: (q: string) => void; @@ -81,7 +81,7 @@ export const AppsAdminActionGroup: React.FC = React.m } + renderInput={(params) => } options={[]} onInputChange={handleSearchChange} clearOnEscape diff --git a/tests/test_app.py b/tests/test_app.py index a3742d9f..2472bd2d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -194,6 +194,32 @@ async def test_get_app_groups_search_by_user(client: AsyncClient, db: Db, url_fo assert [g["id"] for g in rep.json()["items"]] == [group_b.id] +async def test_get_app_groups_search_by_group_name(client: AsyncClient, db: Db, url_for: Any) -> None: + """`?q=` also matches an app group's own name (not just its members), so the + app page's search box can find a specific group or a subset of groups by + name via a case-insensitive substring.""" + app = AppFactory.create() + db.session.add(app) + await db.session.commit() + prd_group = AppGroupFactory.create(app_id=app.id, name="App-Github-Prd") + stg_group = AppGroupFactory.create(app_id=app.id, name="App-Github-Stg") + db.session.add_all([prd_group, stg_group]) + await db.session.commit() + + app_id = app.id + prd_group_id = prd_group.id + db.session.expunge_all() + + url = url_for("api-apps.app_groups_by_id", app_id=app_id) + rep = await client.get(url, params={"q": "App-Github-Prd"}) + assert rep.status_code == 200, rep.text + assert [g["id"] for g in rep.json()["items"]] == [prd_group_id] + + # case-insensitive subset query matches on name + rep = await client.get(url, params={"q": "prd"}) + assert [g["id"] for g in rep.json()["items"]] == [prd_group_id] + + async def test_get_app_groups_size_capped_at_10(client: AsyncClient, db: Db, url_for: Any) -> None: """Requesting more than 10 per page is rejected so the bound can't be opted out of.""" app = AppFactory.create() From 8385743a36071deab06a3e5092565ff83f2461bd Mon Sep 17 00:00:00 2001 From: Jeff Rowell Date: Thu, 9 Jul 2026 14:10:21 -0700 Subject: [PATCH 2/2] chore: sync generated API client with app-groups docstring Regenerate the app-groups endpoint description in the OpenAPI client to match the updated backend docstring, keeping the openapi-client-drift check green. --- src/api/apiComponents.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/api/apiComponents.ts b/src/api/apiComponents.ts index 7a3082c6..2113b0c0 100644 --- a/src/api/apiComponents.ts +++ b/src/api/apiComponents.ts @@ -637,9 +637,10 @@ export type AppGroupsByIdVariables = { * many members any single group has. The UI fetches a group's members on * demand from `GET /api/groups/{id}/member-details`. * - * `owner` filters to owner / non-owner app-groups. `q` filters to groups that - * have an active member matching the query by name or email — the app page's - * user search, computed in SQL so it doesn't need every member client-side. + * `owner` filters to owner / non-owner app-groups. `q` is the app page's + * search: it filters to groups whose own name matches the query, or that have + * an active member matching by name or email, computed in SQL so it doesn't + * need every member client-side. */ export const fetchAppGroupsById = (variables: AppGroupsByIdVariables, signal?: AbortSignal) => apiFetch< @@ -658,9 +659,10 @@ export const fetchAppGroupsById = (variables: AppGroupsByIdVariables, signal?: A * many members any single group has. The UI fetches a group's members on * demand from `GET /api/groups/{id}/member-details`. * - * `owner` filters to owner / non-owner app-groups. `q` filters to groups that - * have an active member matching the query by name or email — the app page's - * user search, computed in SQL so it doesn't need every member client-side. + * `owner` filters to owner / non-owner app-groups. `q` is the app page's + * search: it filters to groups whose own name matches the query, or that have + * an active member matching by name or email, computed in SQL so it doesn't + * need every member client-side. */ export function appGroupsByIdQuery(variables: AppGroupsByIdVariables): { queryKey: reactQuery.QueryKey; @@ -695,9 +697,10 @@ export function appGroupsByIdQuery(variables: AppGroupsByIdVariables | reactQuer * many members any single group has. The UI fetches a group's members on * demand from `GET /api/groups/{id}/member-details`. * - * `owner` filters to owner / non-owner app-groups. `q` filters to groups that - * have an active member matching the query by name or email — the app page's - * user search, computed in SQL so it doesn't need every member client-side. + * `owner` filters to owner / non-owner app-groups. `q` is the app page's + * search: it filters to groups whose own name matches the query, or that have + * an active member matching by name or email, computed in SQL so it doesn't + * need every member client-side. */ export const useSuspenseAppGroupsById = ( variables: AppGroupsByIdVariables, @@ -721,9 +724,10 @@ export const useSuspenseAppGroupsById = ( variables: AppGroupsByIdVariables | reactQuery.SkipToken,