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...
)}