Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions api/routers/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 16 additions & 12 deletions src/api/apiComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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;
Expand Down Expand Up @@ -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 = <TData = Schemas.PageTypeVarCustomizedAppGroupForAppDetail>(
variables: AppGroupsByIdVariables,
Expand All @@ -721,9 +724,10 @@ export const useSuspenseAppGroupsById = <TData = Schemas.PageTypeVarCustomizedAp
* 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 useAppGroupsById = <TData = Schemas.PageTypeVarCustomizedAppGroupForAppDetail>(
variables: AppGroupsByIdVariables | reactQuery.SkipToken,
Expand Down
5 changes: 3 additions & 2 deletions src/api/infiniteAppGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/pages/apps/Read.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/pages/apps/components/AppsAdminActionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,7 +81,7 @@ export const AppsAdminActionGroup: React.FC<AppsAdminActionGroupProps> = React.m
<Autocomplete
size="small"
sx={{flex: '1 1 220px', minWidth: 0, maxWidth: 320}}
renderInput={(params) => <TextField {...params} label="Search Users" />}
renderInput={(params) => <TextField {...params} label="Search groups or users" />}
options={[]}
onInputChange={handleSearchChange}
clearOnEscape
Expand Down
26 changes: 26 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down