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
3 changes: 2 additions & 1 deletion .claude/bash-redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"Bash(bun tsc *)",
"Bash(bun prettier *)",
"Bash(bunx prettier *)",
"Bash(tsc *)"
"Bash(tsc *)",
"Bash(./node_modules/.bin/tsc *)"
],
"reason": "Use bun lint, bun typecheck, or bun format instead of invoking tools directly."
},
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/fake_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export class FakeApi implements Api {

async findMaps(): Promise<FindMapsResponse> {
await delay();
return { success: true, maps: fakeMaps };
return { success: true, maps: fakeMaps, totalCount: fakeMaps.length };
}
async searchMaps(_req: SearchMapsRequest): Promise<FindMapsResponse> {
await delay();
return { success: true, maps: fakeMaps };
return { success: true, maps: fakeMaps, totalCount: fakeMaps.length };
}
async getMap(id: string): Promise<GetMapResponse> {
await delay(1000);
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/maps/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
errorMessage: 'Could not retrieve map: ' + joinErrors(result),
});
}
return send({ success: true, maps: result.value });
return send({ success: true, maps: result.value.maps, totalCount: result.value.totalCount });
}
7 changes: 6 additions & 1 deletion src/app/map_list_presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class MapListStore {
@observable accessor selectedMaps = new Map<string, true>();
@observable accessor lastSelectedMapIndex: number | undefined = undefined;
@observable accessor maps: PDMap[] | undefined = undefined;
@observable accessor totalCount: number | undefined = undefined;
@observable accessor hasMore = true;
@observable accessor loadingMore = false;

Expand Down Expand Up @@ -151,7 +152,10 @@ export class MapListPresenter {
}
}
const sort = this.getTableSortParams();
runInAction(() => (this.store.maps = undefined));
runInAction(() => {
this.store.maps = undefined;
this.store.totalCount = undefined;
});
const resp = await this.api.searchMaps({
query: this.store.query,
limit: SEARCH_LIMIT,
Expand All @@ -162,6 +166,7 @@ export class MapListPresenter {
if (resp.success) {
runInAction(() => {
this.store.maps = resp.maps;
this.store.totalCount = resp.totalCount;
this.store.hasMore = resp.maps.length >= SEARCH_LIMIT;
});
}
Expand Down
6 changes: 6 additions & 0 deletions src/app/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { encodeFilter } from 'schema/map_filter';
import { Button } from 'ui/base/button/button';
import { filterIcon } from 'ui/base/icons/filter_icon';
import { searchIcon } from 'ui/base/icons/search_icon';
import { T } from 'ui/base/text/text';
import { Textbox } from 'ui/base/textbox/textbox';
import styles from './search.module.css';

Expand Down Expand Up @@ -83,6 +84,11 @@ export const Search = observer((props: { store: MapListStore; presenter: MapList
</div>
{!store.filtersExpanded && <ActiveFilterPills store={store} onSearch={onSearch} />}
{store.filtersExpanded && <FilterBuilder store={store} onSearch={onSearch} />}
{store.totalCount != null && (
<T.Tiny color="grey" display="block">
{store.totalCount} {store.totalCount === 1 ? 'map' : 'maps'}
</T.Tiny>
)}
</div>
);
});
2 changes: 2 additions & 0 deletions src/schema/maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export type DeleteMapResponse = z.infer<typeof DeleteMapResponse>;
/* GET findMaps */
export const FindMapsSuccess = ApiSuccess.extend({
maps: z.array(PDMap),
// Total number of maps matching the query, ignoring pagination.
totalCount: z.number(),
});
export type FindMapsSuccess = z.infer<typeof FindMapsSuccess>;

Expand Down
10 changes: 8 additions & 2 deletions src/services/maps/maps_repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export class MapsRepo {
offset: number;
limit: number;
filter?: FilterNode;
}): PromisedResult<PDMap[], DbError> {
}): PromisedResult<{ maps: PDMap[]; totalCount: number }, DbError> {
const { user, query, offset, limit, sort, sortDirection, filter } = searchOptions;
const response = await this.searchIndex.search(query, {
offset,
Expand All @@ -176,7 +176,13 @@ export class MapsRepo {
}

const maps = new Map(mapsResult.value.map((m) => [m.id, m]));
return { success: true, value: searchResults.map((m) => maps.get(m.id)).filter(exists) };
return {
success: true,
value: {
maps: searchResults.map((m) => maps.get(m.id)).filter(exists),
totalCount: response.totalCount,
},
};
}

async getMap(mapId: string, userId?: string): PromisedResult<PDMap, GetMapError> {
Expand Down
2 changes: 1 addition & 1 deletion src/services/maps/tests/maps_repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('maps repo', () => {
limit: 5,
});
expect(result.success).toBe(true);
const ids = (result as Extract<typeof result, { success: true }>).value.map((m) => m.id);
const ids = (result as Extract<typeof result, { success: true }>).value.maps.map((m) => m.id);
expect(ids.includes('3')).toBe(false);
});

Expand Down
21 changes: 20 additions & 1 deletion src/services/maps/tests/maps_repo_filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function searchIds(filter: FilterNode, query = '') {
if (!result.success) {
throw new Error('searchMaps failed');
}
return result.value.map((m) => m.id).sort();
return result.value.maps.map((m) => m.id).sort();
}

describe('maps repo search filters', () => {
Expand Down Expand Up @@ -162,6 +162,25 @@ describe('maps repo search filters', () => {
expect(ids).toEqual(['200']);
});

it('reports the full match count regardless of the page limit', async () => {
for (const id of ['600', '601', '602']) {
await insertMap({ id, artist: 'CountTest' });
}
const { mapsRepo } = await getServerContext();
const result = await mapsRepo.searchMaps({
query: '',
offset: 0,
limit: 2,
filter: { type: 'cmp', field: 'artist', op: 'contains', value: 'CountTest' },
});
if (!result.success) {
throw new Error('searchMaps failed');
}
// The page is capped at the limit, but totalCount counts all matching maps.
expect(result.value.maps).toHaveLength(2);
expect(result.value.totalCount).toBe(3);
});

describe('LIKE-wildcard escaping', () => {
it('treats % in a contains value literally', async () => {
await insertMap({ id: '300', description: '100% complete' });
Expand Down
52 changes: 27 additions & 25 deletions src/services/search/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ export class PostgresIndex implements SearchIndex {
}

let results;
let totalCount: number;
const queryMostRecent = () => {
return db
.select(
'maps',
db.conditions.and(
{ visibility: MapVisibility.PUBLIC },
...(filter ? [compileFilter(filter)] : [])
),
{
const conditions = db.conditions.and(
{ visibility: MapVisibility.PUBLIC },
...(filter ? [compileFilter(filter)] : [])
);
return Promise.all([
db
.select('maps', conditions, {
columns: ['id'],
lateral: sortLateral,
order: sortOrder ?? {
Expand All @@ -65,20 +65,21 @@ export class PostgresIndex implements SearchIndex {
},
limit,
offset,
}
)
.run(pool);
})
.run(pool),
db.count('maps', conditions).run(pool),
]);
};

if (query.trim() === '') {
results = await queryMostRecent();
[results, totalCount] = await queryMostRecent();
} else {
const [{ tsquery }] = await db.sql<
db.Parameter,
[{ tsquery: string }]
>`select websearch_to_tsquery('english', ${db.param(query)})::text as tsquery`.run(pool);
if (tsquery.trim() === '') {
results = await queryMostRecent();
[results, totalCount] = await queryMostRecent();
} else {
const tsqueryPartial = db.sql<maps.SQL, string>`(${db.param(tsquery)} || ':*')::tsquery`;

Expand All @@ -91,15 +92,14 @@ export class PostgresIndex implements SearchIndex {
const ftsMatch = db.sql<maps.SQL, boolean>`${'fts'} @@ ${tsqueryPartial}`;
const rank = db.sql<maps.SQL, number>`ts_rank_cd(${'fts'}, ${tsqueryPartial})`;

results = await db
.select(
'maps',
db.conditions.and(
{ visibility: MapVisibility.PUBLIC },
db.sql`(${exactMatch} OR ${ftsMatch})`,
...(filter ? [compileFilter(filter)] : [])
),
{
const conditions = db.conditions.and(
{ visibility: MapVisibility.PUBLIC },
db.sql`(${exactMatch} OR ${ftsMatch})`,
...(filter ? [compileFilter(filter)] : [])
);
[results, totalCount] = await Promise.all([
db
.select('maps', conditions, {
columns: ['id'],
lateral: sortLateral,
order: sortOrder ?? [
Expand All @@ -112,14 +112,16 @@ export class PostgresIndex implements SearchIndex {
rank,
exactMatch,
},
}
)
.run(pool);
})
.run(pool),
db.count('maps', conditions).run(pool),
]);
}
}

return {
hits: results.map((r) => ({ id: r.id })),
totalCount,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/services/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { MapSortableAttributes, PDMap } from 'schema/maps';
// Input type for indexing maps - accepts PDMap or partial with required id
export type MapDocument = Partial<PDMap> & { id: string };

// Search result - returns matching IDs
// Search result - returns matching IDs along with the total count of matches ignoring pagination
export type SearchResult = {
hits: Array<{ id: string }>;
totalCount: number;
};

export type SortOption = {
Expand Down
Loading