From bde62d3db10cb054757ec704078482fbf016ab51 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:20:43 +0500 Subject: [PATCH] Add search across titles, descriptions and comments Closes #11. Matching, ranking and scoping all stay on the server, which is what makes the result set the same one web shows -- the client only settles the query before sending it and pages through what comes back. Two characters before the first request: a single character matches most of an instance and is never what anyone meant. Each result says where the match was found, because a hit on a comment and a hit on a title look identical otherwise, and carries the snippet that explains why it is there. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-search.test.ts | 103 ++++++++++++ src/app/(app)/search.tsx | 6 +- src/search/search-screen.tsx | 219 ++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/api/__tests__/live-search.test.ts create mode 100644 src/search/search-screen.tsx diff --git a/src/api/__tests__/live-search.test.ts b/src/api/__tests__/live-search.test.ts new file mode 100644 index 0000000..de7613b --- /dev/null +++ b/src/api/__tests__/live-search.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { searchSearchGet } from '@/api/generated/endpoints/search/search'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Search against a real instance. + * + * The acceptance criterion for #11 is the same result set as web for the same + * query and scope, so what matters is that matching, ranking and scoping all + * stay on the server -- the client only debounces and pages. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const NEEDLE = `zqx${RUN}`; + +describeLive('search against a live instance', () => { + let team: TeamRead; + const created: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + + const inTitle = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Title match ${NEEDLE}`, + }); + const inDescription = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Description holder ${RUN}`, + description: `body mentions ${NEEDLE} here`, + }); + const inComment = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Comment holder ${RUN}`, + }); + await createCommentIssuesIssueIdCommentsPost(inComment.id, { + body: `a comment saying ${NEEDLE}`, + }); + created.push(inTitle.id, inDescription.id, inComment.id); + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('searches titles, descriptions and comments alike', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + const ids = page.items.map((hit) => hit.id); + + for (const id of created) expect(ids).toContain(id); + + // `matched_in` is what the result row labels itself with. + const kinds = new Set(page.items.map((hit) => hit.matched_in)); + expect(kinds.size).toBeGreaterThan(1); + }); + + it('carries a snippet, which is why the result is worth a line', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + for (const hit of page.items) { + expect(typeof hit.snippet).toBe('string'); + expect(hit.identifier).toContain(hit.team_key); + } + }); + + it('scopes to one team when asked', async () => { + const scoped = await searchSearchGet({ q: NEEDLE, team_id: team.id, limit: 25 }); + for (const hit of scoped.items) expect(hit.team_id).toBe(team.id); + expect(scoped.items.length).toBeGreaterThan(0); + }); + + it('pages, which is what the "show more" control walks through', async () => { + const first = await searchSearchGet({ q: NEEDLE, limit: 1, offset: 0 }); + expect(first.items).toHaveLength(1); + expect(first.total).toBeGreaterThanOrEqual(3); + + const wider = await searchSearchGet({ q: NEEDLE, limit: 25 }); + expect(wider.items.length).toBe(wider.total); + }); + + it('finds nothing for a query that matches nothing', async () => { + const page = await searchSearchGet({ q: `${NEEDLE}-absent`, limit: 25 }); + expect(page.items).toEqual([]); + expect(page.total).toBe(0); + }); +}); diff --git a/src/app/(app)/search.tsx b/src/app/(app)/search.tsx index a7d84ea..4c1673c 100644 --- a/src/app/(app)/search.tsx +++ b/src/app/(app)/search.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function SearchRoute() { - return ; -} +export { SearchScreen as default } from '@/search/search-screen'; diff --git a/src/search/search-screen.tsx b/src/search/search-screen.tsx new file mode 100644 index 0000000..5cc8aa2 --- /dev/null +++ b/src/search/search-screen.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +import { useSearchSearchGet } from '@/api/generated/endpoints/search/search'; +import type { SearchHit } from '@/api/generated/models'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { useIsMultiPane } from '@/ui/layout'; +import { AppText, Card, Field, Loading, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * Search, per `docs/design/mobile/148-search.svg`. + * + * The query settles before it is sent, so typing a word does not cost a request + * per keystroke. Scope is all your teams or one of them, which is exactly the + * `team_id` parameter the endpoint takes -- ranking and matching stay on the + * server, so the result set is the web's. + */ +const DEBOUNCE_MS = 300; +const PAGE = 25; + +/** Where the match was found, as something to read rather than a column name. */ +function matchedInLabel(hit: SearchHit): string { + switch (hit.matched_in) { + case 'title': + return 'Title'; + case 'description': + return 'Description'; + case 'comment': + return 'Comment'; + default: + return hit.matched_in; + } +} + +export function SearchScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const { teams } = useTeams(); + + const [query, setQuery] = useState(''); + const [scopeTeamId, setScopeTeamId] = useState(null); + const [scoping, setScoping] = useState(false); + const [limit, setLimit] = useState(PAGE); + + const settled = useDebouncedValue(query.trim(), DEBOUNCE_MS); + + const results = useSearchSearchGet( + { q: settled, team_id: scopeTeamId ?? undefined, limit, offset: 0 }, + // A single character matches most of an instance and is never what anyone + // meant, so the request waits for a second one. + { query: { enabled: settled.length >= 2 } }, + ); + + const hits = results.data?.items ?? []; + const total = results.data?.total ?? 0; + const scopeName = scopeTeamId + ? (teams.find((team) => team.id === scopeTeamId)?.name ?? 'One team') + : 'All teams'; + + return ( + + + Search + + setScoping(true)} + style={{ + alignSelf: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: scopeTeamId ? t.line.navActive : t.line.ghost, + }} + > + + {scopeName} + + + + + {settled.length < 2 ? ( + + + Type at least two characters to search. + + + ) : results.isPending ? ( + + ) : ( + String(hit.id)} + contentContainerStyle={{ padding: 16, gap: 10 }} + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ + + {total} RESULT{total === 1 ? '' : 'S'} + + } + ListEmptyComponent={ + + Nothing matches “{settled}”. + + } + ListFooterComponent={ + total > hits.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + router.push(href(`/issue/${item.id}`))} + > + + + + {item.identifier} + + + {matchedInLabel(item)} + + + + {item.title} + + + {/* The snippet is why this result is here, so it is worth the + line even when it repeats part of the title. */} + {item.snippet ? ( + + {item.snippet} + + ) : null} + + + )} + /> + )} + + setScoping(false)} title="Search in"> + + {[{ id: null, name: 'All teams' }, ...teams].map((option) => { + const id = 'id' in option ? (option.id as number | null) : null; + const active = scopeTeamId === id; + return ( + { + setScopeTeamId(id); + setScoping(false); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + backgroundColor: active ? t.line.navActive : 'transparent', + }} + > + {option.name} + + ); + })} + + + + ); +}