feat: set up command pallette and search endpoint
This commit is contained in:
154
apps/web/src/components/CommandPallette.tsx
Normal file
154
apps/web/src/components/CommandPallette.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog
|
||||||
|
className="relative z-50"
|
||||||
|
open={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
|
||||||
|
>
|
||||||
|
<Combobox>
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
|
<ComboboxInput
|
||||||
|
autoFocus
|
||||||
|
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-base text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700 sm:text-sm"
|
||||||
|
placeholder="Search boards and cards..."
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<HiMagnifyingGlass
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ComboboxOptions
|
||||||
|
static
|
||||||
|
className="max-h-72 scroll-py-2 overflow-y-auto py-2"
|
||||||
|
>
|
||||||
|
{results.map((result) => (
|
||||||
|
<ComboboxOption
|
||||||
|
key={`${result.type}-${result.publicId}`}
|
||||||
|
value={result}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
result.type === "board"
|
||||||
|
? `/boards/${result.publicId}`
|
||||||
|
: `/cards/${result.publicId}`
|
||||||
|
}
|
||||||
|
className="block cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
{result.type === "card" && (
|
||||||
|
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||||
|
{`${t`in`} ${result.boardName} → ${result.listName}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</ComboboxOption>
|
||||||
|
))}
|
||||||
|
</ComboboxOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSearched && !isLoading && results.length === 0 && (
|
||||||
|
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
|
||||||
|
{t`No results found for "${query}".`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { HiCheck } from "react-icons/hi2";
|
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import CommandPallette from "./CommandPallette";
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu({
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
@@ -15,110 +16,125 @@ export default function WorkspaceMenu({
|
|||||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
<>
|
||||||
<div>
|
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
{isLoading ? (
|
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
<div>
|
||||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
{isLoading ? (
|
||||||
<div
|
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||||
className={twMerge(
|
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
<div
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Menu.Button
|
|
||||||
className={twMerge(
|
|
||||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
|
||||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
|
||||||
)}
|
|
||||||
title={isCollapsed ? workspace.name : undefined}
|
|
||||||
>
|
|
||||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
|
||||||
<span className="text-xs font-bold leading-none text-white">
|
|
||||||
{workspace.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{workspace.name}
|
|
||||||
</span>
|
|
||||||
{workspace.plan === "pro" && (
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Menu.Button
|
||||||
|
className={twMerge(
|
||||||
|
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||||
|
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||||
|
)}
|
||||||
|
title={isCollapsed ? workspace.name : undefined}
|
||||||
>
|
>
|
||||||
Pro
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||||
</span>
|
<span className="text-xs font-bold leading-none text-white">
|
||||||
)}
|
{workspace.name.charAt(0).toUpperCase()}
|
||||||
</Menu.Button>
|
</span>
|
||||||
)}
|
</span>
|
||||||
</div>
|
<span
|
||||||
|
className={twMerge(
|
||||||
<Transition
|
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||||
as={Fragment}
|
isCollapsed && "md:hidden",
|
||||||
enter="transition ease-out duration-100"
|
)}
|
||||||
enterFrom="transform opacity-0 scale-95"
|
>
|
||||||
enterTo="transform opacity-100 scale-100"
|
{workspace.name}
|
||||||
leave="transition ease-in duration-75"
|
</span>
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
{workspace.plan === "pro" && (
|
||||||
leaveTo="transform opacity-0 scale-95"
|
<span
|
||||||
>
|
className={twMerge(
|
||||||
<Menu.Items
|
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||||
className={twMerge(
|
isCollapsed && "md:hidden",
|
||||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
|
||||||
isCollapsed ? "w-48" : "w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-1">
|
|
||||||
{availableWorkspaces.map((availableWorkspace) => (
|
|
||||||
<div key={availableWorkspace.publicId} className="flex">
|
|
||||||
<Menu.Item>
|
|
||||||
<button
|
|
||||||
onClick={() => switchWorkspace(availableWorkspace)}
|
|
||||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
|
||||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
|
||||||
<span className="text-xs font-medium leading-none text-white">
|
|
||||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 truncate text-xs font-medium">
|
|
||||||
{availableWorkspace.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{workspace.publicId === availableWorkspace.publicId && (
|
|
||||||
<span>
|
|
||||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
>
|
||||||
</Menu.Item>
|
Pro
|
||||||
</div>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
</Menu.Button>
|
||||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
<Button
|
||||||
<Menu.Item>
|
className={twMerge(
|
||||||
<button
|
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||||
onClick={() => openModal("NEW_WORKSPACE")}
|
isCollapsed && "md:hidden",
|
||||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
)}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
{t`Create workspace`}
|
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</Menu.Item>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Menu.Items>
|
</div>
|
||||||
</Transition>
|
|
||||||
</Menu>
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className={twMerge(
|
||||||
|
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||||
|
isCollapsed ? "w-48" : "w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
{availableWorkspaces.map((availableWorkspace) => (
|
||||||
|
<div key={availableWorkspace.publicId} className="flex">
|
||||||
|
<Menu.Item>
|
||||||
|
<button
|
||||||
|
onClick={() => switchWorkspace(availableWorkspace)}
|
||||||
|
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
|
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||||
|
<span className="text-xs font-medium leading-none text-white">
|
||||||
|
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 truncate text-xs font-medium">
|
||||||
|
{availableWorkspace.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{workspace.publicId === availableWorkspace.publicId && (
|
||||||
|
<span>
|
||||||
|
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Menu.Item>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||||
|
<Menu.Item>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal("NEW_WORKSPACE")}
|
||||||
|
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
|
>
|
||||||
|
{t`Create workspace`}
|
||||||
|
</button>
|
||||||
|
</Menu.Item>
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,4 +409,74 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
isReserved: workspaceSlug?.type === "reserved",
|
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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
@@ -274,3 +280,59 @@ export const isUserInWorkspace = async (
|
|||||||
|
|
||||||
return result?.id !== undefined;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user