mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(search): add ability to load search active views
This commit is contained in:
1 parent
469641de18
commit
90a9942d1b
5 files changed
+96
-6
No files matched your search
@@ -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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user