feat: basic workspace search (#203)

* feat: set up command pallette and search endpoint

* feat: use placeholder data in search results

* feat: add keyboard navigation

* feat: add icons for search results

* feat: add fuzzy search

* feat: add search button to collapsed menu

* feat: add keyboard shortcuts

* fix: prevent no results flickering

* feat: consistent font size for search placeholder

* chore: build translations
This commit is contained in:
Henry
2025-10-02 22:25:48 +01:00
committed by GitHub
parent adbb25edd7
commit fc692cb411
22 changed files with 3631 additions and 319 deletions

View File

@@ -409,4 +409,80 @@ export const workspaceRouter = createTRPCRouter({
isReserved: workspaceSlug?.type === "reserved",
};
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search boards and cards in a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/search",
description:
"Searches for boards and cards by title within a workspace",
tags: ["Workspaces"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
query: z.string().min(1).max(100),
limit: z.number().min(1).max(50).optional().default(20),
}),
)
.output(
z.array(
z.discriminatedUnion("type", [
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
slug: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("board"),
}),
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),
}),
]),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const result = await workspaceRepo.searchBoardsAndCards(
ctx.db,
workspace.id,
input.query,
input.limit,
);
return result;
}),
});

View File

@@ -0,0 +1,4 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm; --> statement-breakpoint
CREATE INDEX IF NOT EXISTS boards_name_trgm_idx ON board USING gin (name gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS cards_title_trgm_idx ON card USING gin (title gin_trgm_ops);

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1758662398166,
"tag": "20250923211958_AddWorkspaceInviteLinks",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1759356096392,
"tag": "20251001220136_AddFuzzySearchSupport",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,13 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { boards, workspaceMembers, workspaces } from "@kan/db/schema";
import {
boards,
cards,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const create = async (
@@ -274,3 +280,84 @@ export const isUserInWorkspace = async (
return result?.id !== undefined;
};
export const searchBoardsAndCards = async (
db: dbClient,
workspaceId: number,
query: string,
limit = 20,
) => {
const searchQuery = `%${query}%`;
// Search for boards
const boardResults = await db
.select({
publicId: boards.publicId,
title: boards.name,
description: boards.description,
slug: boards.slug,
updatedAt: boards.updatedAt,
createdAt: boards.createdAt,
})
.from(boards)
.where(
and(
eq(boards.workspaceId, workspaceId),
// Combine exact and fuzzy matching
or(
ilike(boards.name, `%${query}%`), // Exact substring match
sql`similarity(${boards.name}, ${query}) > 0.2`, // Fuzzy match
),
isNull(boards.deletedAt),
),
)
.orderBy(
sql`CASE WHEN ${boards.name} ILIKE ${`%${query}%`} THEN 1 ELSE 0 END DESC`,
sql`similarity(${boards.name}, ${query}) DESC`,
desc(boards.updatedAt),
)
.limit(Math.ceil(limit * 0.4));
// Search for cards
const cardResults = await db
.select({
publicId: cards.publicId,
title: cards.title,
description: cards.description,
boardPublicId: boards.publicId,
boardName: boards.name,
listName: lists.name,
updatedAt: cards.updatedAt,
createdAt: cards.createdAt,
})
.from(cards)
.innerJoin(lists, eq(cards.listId, lists.id))
.innerJoin(boards, eq(lists.boardId, boards.id))
.where(
and(
eq(boards.workspaceId, workspaceId),
or(
ilike(cards.title, searchQuery),
sql`similarity(${cards.title}, ${query}) > 0.2`,
),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
),
)
.orderBy(
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
)
.limit(Math.floor(limit * 0.6));
// Combine results
const allResults = [
...boardResults.map((board) => ({ ...board, type: "board" as const })),
...cardResults.map((card) => ({ ...card, type: "card" as const })),
];
// Ensure we don't exceed the total limit
return allResults.slice(0, limit);
};