From e07a4621849cc70b58e0e2624c499638b5ccf43f Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 29 Sep 2025 22:33:36 +0100 Subject: [PATCH] feat: set up command pallette and search endpoint --- apps/web/src/components/CommandPallette.tsx | 154 +++++++++++++ apps/web/src/components/WorkspaceMenu.tsx | 216 ++++++++++--------- packages/api/src/routers/workspace.ts | 70 ++++++ packages/db/src/repository/workspace.repo.ts | 66 +++++- 4 files changed, 404 insertions(+), 102 deletions(-) create mode 100644 apps/web/src/components/CommandPallette.tsx diff --git a/apps/web/src/components/CommandPallette.tsx b/apps/web/src/components/CommandPallette.tsx new file mode 100644 index 00000000..8119b8a1 --- /dev/null +++ b/apps/web/src/components/CommandPallette.tsx @@ -0,0 +1,154 @@ +import Link from "next/link"; +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + Dialog, + DialogBackdrop, + DialogPanel, +} from "@headlessui/react"; +import { t } from "@lingui/macro"; +import { useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; + +import { useDebounce } from "~/hooks/useDebounce"; +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; + +type SearchResult = + | { + publicId: string; + title: string; + description: string | null; + slug: string; + type: "board"; + } + | { + publicId: string; + title: string; + description: string | null; + boardPublicId: string; + boardName: string; + listName: string; + type: "card"; + }; + +export default function CommandPallette({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const { workspace } = useWorkspace(); + + // Debounce to avoid too many reqs + const [debouncedQuery] = useDebounce(query, 500); + + const { + data: searchResults, + isLoading, + isFetched, + } = api.workspace.search.useQuery( + { + workspacePublicId: workspace.publicId, + query: debouncedQuery, + }, + { + enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0), + }, + ); + + const results = (searchResults ?? []) as SearchResult[]; + const hasSearched = Boolean(debouncedQuery.trim().length > 0 && isFetched); + + return ( + { + onClose(); + setQuery(""); + }} + > + + +
+
+ + +
+ setQuery(event.target.value)} + /> +
+ + {results.length > 0 && ( + + {results.map((result) => ( + + { + onClose(); + setQuery(""); + }} + > +
+
+
+ {result.title} +
+ {result.type === "card" && ( +
+ {`${t`in`} ${result.boardName} → ${result.listName}`} +
+ )} +
+
+ +
+ ))} +
+ )} + + {hasSearched && !isLoading && results.length === 0 && ( +
+ {t`No results found for "${query}".`} +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/WorkspaceMenu.tsx b/apps/web/src/components/WorkspaceMenu.tsx index 0d18a929..7531290d 100644 --- a/apps/web/src/components/WorkspaceMenu.tsx +++ b/apps/web/src/components/WorkspaceMenu.tsx @@ -1,11 +1,12 @@ -import { Menu, Transition } from "@headlessui/react"; +import { Button, Menu, Transition } from "@headlessui/react"; import { t } from "@lingui/core/macro"; -import { Fragment } from "react"; -import { HiCheck } from "react-icons/hi2"; +import { Fragment, useState } from "react"; +import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; +import CommandPallette from "./CommandPallette"; export default function WorkspaceMenu({ isCollapsed = false, @@ -15,110 +16,125 @@ export default function WorkspaceMenu({ const { workspace, isLoading, availableWorkspaces, switchWorkspace } = useWorkspace(); const { openModal } = useModal(); + const [isOpen, setIsOpen] = useState(false); return ( - -
- {isLoading ? ( -
-
-
-
- ) : ( - - - - {workspace.name.charAt(0).toUpperCase()} - - - - {workspace.name} - - {workspace.plan === "pro" && ( - + setIsOpen(false)} /> + +
+ {isLoading ? ( +
+
+
+
+ ) : ( +
+ - Pro - - )} - - )} -
- - - -
- {availableWorkspaces.map((availableWorkspace) => ( -
- -
- ))} -
-
- - - -
-
-
-
+
+ )} +
+ + + +
+ {availableWorkspaces.map((availableWorkspace) => ( +
+ + + +
+ ))} +
+
+ + + +
+
+
+
+ ); } diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index f0e7d23b..1f201603 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -409,4 +409,74 @@ 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), + }), + ) + .output( + z.array( + z.discriminatedUnion("type", [ + z.object({ + publicId: z.string(), + title: z.string(), + description: z.string().nullable(), + slug: z.string(), + 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(), + 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, + ); + + return result; + }), }); diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 610ff28f..2409e7ec 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -1,7 +1,13 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, ilike, inArray, isNull } 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,59 @@ export const isUserInWorkspace = async ( return result?.id !== undefined; }; + +export const searchBoardsAndCards = async ( + db: dbClient, + workspaceId: number, + query: string, +) => { + const searchQuery = `%${query}%`; + + // Search for boards + const boardResults = await db + .select({ + publicId: boards.publicId, + title: boards.name, + description: boards.description, + slug: boards.slug, + }) + .from(boards) + .where( + and( + eq(boards.workspaceId, workspaceId), + ilike(boards.name, searchQuery), + isNull(boards.deletedAt), + ), + ); + + // 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, + }) + .from(cards) + .innerJoin(lists, eq(cards.listId, lists.id)) + .innerJoin(boards, eq(lists.boardId, boards.id)) + .where( + and( + eq(boards.workspaceId, workspaceId), + ilike(cards.title, searchQuery), + isNull(cards.deletedAt), + isNull(lists.deletedAt), + isNull(boards.deletedAt), + ), + ); + + // Combine results + const allResults = [ + ...boardResults.map((board) => ({ ...board, type: "board" as const })), + ...cardResults.map((card) => ({ ...card, type: "card" as const })), + ]; + + return allResults; +};