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/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, 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()