diff --git a/apps/web/app/api/tags/count/route.ts b/apps/web/app/api/tags/count/route.ts new file mode 100644 index 0000000000..6ae4868d32 --- /dev/null +++ b/apps/web/app/api/tags/count/route.ts @@ -0,0 +1,29 @@ +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { getTagsCountQuerySchema } from "@/lib/zod/schemas/tags"; +import { getSearchParams } from "@dub/utils"; +import { NextResponse } from "next/server"; + +// GET /api/tags - get all tags for a workspace +export const GET = withWorkspace( + async ({ req, workspace, headers }) => { + const searchParams = getSearchParams(req.url); + const { search } = getTagsCountQuerySchema.parse(searchParams); + + const count = await prisma.tag.count({ + where: { + projectId: workspace.id, + ...(search && { + name: { + contains: search, + }, + }), + }, + }); + + return NextResponse.json(count, { headers }); + }, + { + requiredPermissions: ["tags.read"], + }, +); diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts index c012d5a9ef..782f455e34 100644 --- a/apps/web/app/api/tags/route.ts +++ b/apps/web/app/api/tags/route.ts @@ -1,16 +1,29 @@ import { DubApiError, exceededLimitError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { TagSchema, createTagBodySchema } from "@/lib/zod/schemas/tags"; +import { + TagSchema, + createTagBodySchema, + getTagsQuerySchema, +} from "@/lib/zod/schemas/tags"; import { COLORS_LIST, randomBadgeColor } from "@/ui/links/tag-badge"; +import { getSearchParams } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/tags - get all tags for a workspace export const GET = withWorkspace( - async ({ workspace, headers }) => { + async ({ req, workspace, headers }) => { + const searchParams = getSearchParams(req.url); + const { search, page, pageSize } = getTagsQuerySchema.parse(searchParams); + const tags = await prisma.tag.findMany({ where: { projectId: workspace.id, + ...(search && { + name: { + contains: search, + }, + }), }, select: { id: true, @@ -20,6 +33,8 @@ export const GET = withWorkspace( orderBy: { name: "asc", }, + take: pageSize, + skip: (page - 1) * pageSize, }); return NextResponse.json(tags, { headers }); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx index ad7e90af80..75731f5915 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx @@ -4,6 +4,7 @@ import { clientAccessCheck } from "@/lib/api/tokens/permissions"; import useDomains from "@/lib/swr/use-domains"; import useDomainsCount from "@/lib/swr/use-domains-count"; import useWorkspace from "@/lib/swr/use-workspace"; +import { DOMAINS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/domains"; import DomainCard from "@/ui/domains/domain-card"; import DomainCardPlaceholder from "@/ui/domains/domain-card-placeholder"; import { FreeDotLinkBanner } from "@/ui/domains/free-dot-link-banner"; @@ -45,7 +46,7 @@ export default function WorkspaceDomainsClient() { const { allWorkspaceDomains, loading } = useDomains({ includeParams: true }); const { data: domainsCount } = useDomainsCount(); - const { pagination, setPagination } = usePagination(50); + const { pagination, setPagination } = usePagination(DOMAINS_MAX_PAGE_SIZE); const archived = searchParams.get("archived"); const search = searchParams.get("search"); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tags/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tags/page-client.tsx index 6223ad7300..af5c036902 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tags/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tags/page-client.tsx @@ -2,10 +2,14 @@ import useLinksCount from "@/lib/swr/use-links-count"; import useTags from "@/lib/swr/use-tags"; +import useTagsCount from "@/lib/swr/use-tags-count"; import useWorkspace from "@/lib/swr/use-workspace"; +import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags"; import { useAddEditTagModal } from "@/ui/modals/add-edit-tag-modal"; import EmptyState from "@/ui/shared/empty-state"; -import { CardList } from "@dub/ui"; +import { SearchBoxPersisted } from "@/ui/shared/search-box"; +import { PaginationControls } from "@dub/blocks"; +import { CardList, usePagination, useRouterStuff } from "@dub/ui"; import { Tag } from "@dub/ui/src/icons"; import { InfoTooltip, TooltipContent } from "@dub/ui/src/tooltip"; import { Dispatch, SetStateAction, createContext, useState } from "react"; @@ -21,12 +25,21 @@ export const TagsListContext = createContext<{ }); export default function WorkspaceTagsClient() { + const { searchParams, queryParams } = useRouterStuff(); const { id: workspaceId } = useWorkspace(); const { AddEditTagModal, AddTagButton } = useAddEditTagModal(); - const { tags, loading } = useTags(); - const { data: tagsCount } = useLinksCount< + const search = searchParams.get("search"); + const { pagination, setPagination } = usePagination(TAGS_MAX_PAGE_SIZE); + + const { tags, loading } = useTags({ + query: { search: search ?? "", page: pagination.pageIndex }, + }); + const { data: tagsCount } = useTagsCount({ + query: { search: search ?? "" }, + }); + const { data: tagLinksCount } = useLinksCount< { tagId: string; _count: number; @@ -40,7 +53,7 @@ export default function WorkspaceTagsClient() { return ( <> -
+

@@ -58,6 +71,16 @@ export default function WorkspaceTagsClient() { />

+ { + if (t) { + queryParams({ set: { search: t }, del: "page" }); + } else { + queryParams({ del: "search" }); + } + }} + />
@@ -68,7 +91,7 @@ export default function WorkspaceTagsClient() { {tags?.length ? tags.map((tag) => ( - + )) : Array.from({ length: 6 }).map((_, idx) => ( @@ -81,6 +104,15 @@ export default function WorkspaceTagsClient() {
)} + +
+ `tag${p ? "s" : ""}`} + /> +
); diff --git a/apps/web/lib/swr/use-tags-count.ts b/apps/web/lib/swr/use-tags-count.ts new file mode 100644 index 0000000000..9a1311f93f --- /dev/null +++ b/apps/web/lib/swr/use-tags-count.ts @@ -0,0 +1,28 @@ +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import { z } from "zod"; +import { getTagsCountQuerySchema } from "../zod/schemas/tags"; +import useWorkspace from "./use-workspace"; + +const partialQuerySchema = getTagsCountQuerySchema.partial(); + +export default function useTagsCount({ + query, +}: { query?: z.infer } = {}) { + const { id } = useWorkspace(); + + const { data, error } = useSWR( + id && + `/api/tags/count?${new URLSearchParams({ workspaceId: id, ...query } as Record).toString()}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + return { + data, + loading: !error && data === undefined, + error, + }; +} diff --git a/apps/web/lib/swr/use-tags.ts b/apps/web/lib/swr/use-tags.ts index d2c0decf29..c833ca22de 100644 --- a/apps/web/lib/swr/use-tags.ts +++ b/apps/web/lib/swr/use-tags.ts @@ -1,13 +1,20 @@ import { TagProps } from "@/lib/types"; import { fetcher } from "@dub/utils"; import useSWR from "swr"; +import { z } from "zod"; +import { getTagsQuerySchema } from "../zod/schemas/tags"; import useWorkspace from "./use-workspace"; -export default function useTags() { +const partialQuerySchema = getTagsQuerySchema.partial(); + +export default function useTags({ + query, +}: { query?: z.infer } = {}) { const { id } = useWorkspace(); const { data: tags, isValidating } = useSWR( - id && `/api/tags?workspaceId=${id}`, + id && + `/api/tags?${new URLSearchParams({ workspaceId: id, ...query } as Record).toString()}`, fetcher, { dedupingInterval: 60000, diff --git a/apps/web/lib/zod/schemas/tags.ts b/apps/web/lib/zod/schemas/tags.ts index ed4c7a668b..f75bed045d 100644 --- a/apps/web/lib/zod/schemas/tags.ts +++ b/apps/web/lib/zod/schemas/tags.ts @@ -1,5 +1,22 @@ import { tagColors } from "@/lib/types"; import z from "@/lib/zod"; +import { getPaginationQuerySchema } from "./misc"; + +export const TAGS_MAX_PAGE_SIZE = 100; + +export const getTagsQuerySchema = z + .object({ + search: z + .string() + .optional() + .describe("The search term to filter the tags by."), + }) + .merge(getPaginationQuerySchema({ pageSize: TAGS_MAX_PAGE_SIZE })); + +export const getTagsCountQuerySchema = getTagsQuerySchema.omit({ + page: true, + pageSize: true, +}); export const tagColorSchema = z .enum(tagColors, { diff --git a/apps/web/ui/modals/link-builder/tag-select.tsx b/apps/web/ui/modals/link-builder/tag-select.tsx index a5cf9d4545..02877b93c6 100644 --- a/apps/web/ui/modals/link-builder/tag-select.tsx +++ b/apps/web/ui/modals/link-builder/tag-select.tsx @@ -1,6 +1,8 @@ import useTags from "@/lib/swr/use-tags"; +import useTagsCount from "@/lib/swr/use-tags-count"; import useWorkspace from "@/lib/swr/use-workspace"; import { TagProps } from "@/lib/types"; +import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags"; import TagBadge from "@/ui/links/tag-badge"; import { AnimatedSizeContainer, @@ -23,10 +25,28 @@ import { useDebounce } from "use-debounce"; import { LinkFormData, LinkModalContext } from "."; import { MultiTagsIcon } from "./multi-tags-icon"; +function getTagOption(tag: TagProps) { + return { + value: tag.id, + label: tag.name, + icon: , + meta: { color: tag.color }, + }; +} + export function TagSelect() { const { mutate: mutateWorkspace, exceededAI } = useWorkspace(); const { workspaceId } = useContext(LinkModalContext); - const { tags: availableTags } = useTags(); + + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebounce(search, 500); + + const { data: tagsCount } = useTagsCount(); + const useAsync = tagsCount && tagsCount > TAGS_MAX_PAGE_SIZE; + + const { tags: availableTags, loading: loadingTags } = useTags({ + query: useAsync ? { search: debouncedSearch } : undefined, + }); const { watch, setValue } = useFormContext(); const [tags, linkId, url, title, description] = watch([ @@ -65,24 +85,13 @@ export function TagSelect() { }; const options = useMemo( - () => - availableTags?.map((tag) => ({ - label: tag.name, - value: tag.id, - icon: , - meta: { - color: tag.color, - }, - })), + () => availableTags?.map((tag) => getTagOption(tag)), [availableTags], ); const selectedTags = useMemo( - () => - tags - .map(({ id }) => options?.find(({ value }) => value === id)!) - .filter(Boolean), - [tags, options], + () => tags.map((tag) => getTagOption(tag)), + [tags], ); useKeyboardShortcut("t", () => setIsOpen(true), { modal: true }); @@ -159,15 +168,19 @@ export function TagSelect() { { - const selectedIds = tags.map(({ value }) => value); + setSelected={(newTags) => { + const selectedIds = newTags.map(({ value }) => value); setValue( "tags", - selectedIds.map((id) => availableTags?.find((t) => t.id === id)), + selectedIds.map((id) => + [...(availableTags || []), ...(tags || [])]?.find( + (t) => t.id === id, + ), + ), { shouldDirty: true }, ); }} - options={options} + options={loadingTags ? undefined : options} icon={} searchPlaceholder="Search or add tags..." shortcutHint="T" @@ -180,6 +193,8 @@ export function TagSelect() { onCreate={(search) => createTag(search)} open={isOpen} onOpenChange={setIsOpen} + onSearchChange={setSearch} + shouldFilter={!useAsync} matchTriggerWidth > {selectedTags.length > 0 ? ( @@ -193,6 +208,8 @@ export function TagSelect() { /> ))} + ) : loadingTags && availableTags === undefined && tags.length ? ( +
) : ( Select tags... )}