feat(search): add ability to load search active views

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-08 08:36:23 +02:00
1 parent 469641de18
commit 90a9942d1b
5 files changed
+96 -6

No files matched your search

+8 -2
View File
@@ -6,12 +6,18 @@ import { search } from "../services/searchService";
const router = Router();
router.use(requireAuth);
// A blank `q` returns empty results.
const MAX_LIMIT = 25;
// A blank `q` returns empty results. `limit` raises how many models and collections come back, for
// clients that show more than the search palette (or want to know if there are more).
router.get(
"/search",
asyncHandler(async (req, res) => {
const q = typeof req.query.q === "string" ? req.query.q : "";
res.json(await search(req.userId!, q));
const limit = Number.parseInt(typeof req.query.limit === "string" ? req.query.limit : "", 10);
res.json(
await search(req.userId!, q, Number.isFinite(limit) ? Math.min(Math.max(limit, 1), MAX_LIMIT) : undefined),
);
}),
);
+3 -1
View File
@@ -56,7 +56,7 @@ export async function printOutsByIds(userId: string, printIds: string[]): Promis
const out = new Map<string, PrintOut>();
if (!printIds.length) return out;
const [prints, plates, files, previewImages] = await Promise.all([
prisma.print.findMany({ where: { id: { in: printIds }, userId }, include: { author: true } }),
prisma.print.findMany({ where: { id: { in: printIds }, userId }, include: { author: true, category: true } }),
prisma.plate.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
prisma.printFile.findMany({ where: { printId: { in: printIds } } }),
prisma.previewImage.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
@@ -76,6 +76,8 @@ export async function printOutsByIds(userId: string, printIds: string[]): Promis
preparedFile,
print.author,
previewsByPrint.get(print.id) || [],
// The search palette shows it under each model.
print.category,
),
);
}
+4 -3
View File
@@ -79,13 +79,14 @@ async function searchTags(userId: string, rawQuery: string, limit: number): Prom
return rows.map((r) => ({ tag: r.tag, count: Number(r.count) }));
}
export async function search(userId: string, rawQuery: string): Promise<SearchResult> {
/** `limit`, when given, replaces the model and collection limits; tags keep theirs. */
export async function search(userId: string, rawQuery: string, limit?: number): Promise<SearchResult> {
const tsQuery = buildPrefixTsQuery(rawQuery);
if (!tsQuery) return EMPTY_RESULT;
const [modelIds, collections, tags] = await Promise.all([
searchPrintIds(userId, tsQuery, MODEL_RESULT_LIMIT),
searchCollections(userId, tsQuery, COLLECTION_RESULT_LIMIT),
searchPrintIds(userId, tsQuery, limit ?? MODEL_RESULT_LIMIT),
searchCollections(userId, tsQuery, limit ?? COLLECTION_RESULT_LIMIT),
searchTags(userId, rawQuery.trim(), TAG_RESULT_LIMIT),
]);
+60
View File
@@ -0,0 +1,60 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
const app = createApp();
const stamp = Date.now();
// One word that matches nothing else in a shared test database.
const word = `zorblax${stamp}`;
let session: string;
let apiToken: string;
function auth(t: string) {
return { Authorization: `Bearer ${t}` };
}
beforeAll(async () => {
const registered = await request(app)
.post("/api/register")
.send({ displayName: "Search Test", email: `search-test-${stamp}@example.com`, password: "password123" });
if (registered.status !== 200) {
throw new Error(`Failed to register during test setup: ${registered.status} ${JSON.stringify(registered.body)}`);
}
session = registered.body.token;
const userId = registered.body.user.id;
for (let i = 0; i < 9; i++) {
const name = `${word} model ${i}`;
await prisma.print.create({ data: { userId, name, nameNormalized: name.toLowerCase() } });
}
apiToken = (await request(app).post("/api/profile/api-token").set(auth(session))).body.token;
});
describe("GET /search", () => {
it("returns the palette's six models by default", async () => {
const res = await request(app).get("/api/search").query({ q: word }).set(auth(session));
expect(res.status).toBe(200);
expect(res.body.models).toHaveLength(6);
});
it("returns more with a limit, for clients that show more", async () => {
const res = await request(app).get("/api/search").query({ q: word, limit: 8 }).set(auth(session));
expect(res.body.models).toHaveLength(8);
});
it("caps the limit", async () => {
const res = await request(app).get("/api/search").query({ q: word, limit: 1000 }).set(auth(session));
expect(res.body.models).toHaveLength(9);
});
it("ignores a limit that isn't a number", async () => {
const res = await request(app).get("/api/search").query({ q: word, limit: "lots" }).set(auth(session));
expect(res.body.models).toHaveLength(6);
});
it("works with an API token", async () => {
const res = await request(app).get("/api/search").query({ q: word }).set(auth(apiToken));
expect(res.status).toBe(200);
expect(res.body.models[0].name).toContain(word);
});
});
@@ -159,6 +159,27 @@ export default function GlobalSearch({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// `?q=` on any page opens the search with that query, for links from elsewhere (a dashboard's
// "More in Thingport"). The parameter is dropped straight away so a reload doesn't reopen it.
useEffect(() => {
const params = new URLSearchParams(location.search);
const linked = params.get("q");
if (linked === null) return;
params.delete("q");
const search = params.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "", hash: location.hash },
{ replace: true },
);
if (!linked.trim()) return;
returnFocusRef.current = null;
setQuery(linked);
userMovedRef.current = false;
setOpen(true);
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
// Locks page scroll while open, padding for the scrollbar so the content doesn't shift.
useEffect(() => {
if (!open) return;