Skip to content

Commit

Permalink
Add pagination and filtering to tags
Browse files Browse the repository at this point in the history
  • Loading branch information
TWilson023 committed Oct 10, 2024
1 parent 0011aaa commit c0a369d
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 29 deletions.
29 changes: 29 additions & 0 deletions apps/web/app/api/tags/count/route.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
);
19 changes: 17 additions & 2 deletions apps/web/app/api/tags/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +33,8 @@ export const GET = withWorkspace(
orderBy: {
name: "asc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});

return NextResponse.json(tags, { headers });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -40,7 +53,7 @@ export default function WorkspaceTagsClient() {

return (
<>
<div className="grid gap-5">
<div className="grid gap-5 pb-10">
<div className="flex flex-wrap justify-between gap-6">
<div className="flex items-center gap-x-2">
<h1 className="text-2xl font-semibold tracking-tight text-black">
Expand All @@ -58,6 +71,16 @@ export default function WorkspaceTagsClient() {
/>
</div>
<div className="flex w-full flex-wrap items-center gap-3 sm:w-auto">
<SearchBoxPersisted
loading={loading}
onChangeDebounced={(t) => {
if (t) {
queryParams({ set: { search: t }, del: "page" });
} else {
queryParams({ del: "search" });
}
}}
/>
<AddTagButton />
</div>
</div>
Expand All @@ -68,7 +91,7 @@ export default function WorkspaceTagsClient() {
<CardList variant="compact" loading={loading}>
{tags?.length
? tags.map((tag) => (
<TagCard key={tag.id} tag={tag} tagsCount={tagsCount} />
<TagCard key={tag.id} tag={tag} tagsCount={tagLinksCount} />
))
: Array.from({ length: 6 }).map((_, idx) => (
<TagCardPlaceholder key={idx} />
Expand All @@ -81,6 +104,15 @@ export default function WorkspaceTagsClient() {
<AddTagButton />
</div>
)}

<div className="sticky bottom-0 rounded-b-[inherit] border-t border-gray-200 bg-white px-3.5 py-2">
<PaginationControls
pagination={pagination}
setPagination={setPagination}
totalCount={tagsCount || 0}
unit={(p) => `tag${p ? "s" : ""}`}
/>
</div>
</div>
</>
);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/lib/swr/use-tags-count.ts
Original file line number Diff line number Diff line change
@@ -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<typeof partialQuerySchema> } = {}) {
const { id } = useWorkspace();

const { data, error } = useSWR<number>(
id &&
`/api/tags/count?${new URLSearchParams({ workspaceId: id, ...query } as Record<string, any>).toString()}`,
fetcher,
{
dedupingInterval: 60000,
},
);

return {
data,
loading: !error && data === undefined,
error,
};
}
11 changes: 9 additions & 2 deletions apps/web/lib/swr/use-tags.ts
Original file line number Diff line number Diff line change
@@ -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<typeof partialQuerySchema> } = {}) {
const { id } = useWorkspace();

const { data: tags, isValidating } = useSWR<TagProps[]>(
id && `/api/tags?workspaceId=${id}`,
id &&
`/api/tags?${new URLSearchParams({ workspaceId: id, ...query } as Record<string, any>).toString()}`,
fetcher,
{
dedupingInterval: 60000,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/lib/zod/schemas/tags.ts
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down
55 changes: 36 additions & 19 deletions apps/web/ui/modals/link-builder/tag-select.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: <MultiTagsIcon tags={[tag]} />,
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<LinkFormData>();
const [tags, linkId, url, title, description] = watch([
Expand Down Expand Up @@ -65,24 +85,13 @@ export function TagSelect() {
};

const options = useMemo(
() =>
availableTags?.map((tag) => ({
label: tag.name,
value: tag.id,
icon: <MultiTagsIcon tags={[tag]} />,
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 });
Expand Down Expand Up @@ -159,15 +168,19 @@ export function TagSelect() {
<Combobox
multiple
selected={selectedTags}
setSelected={(tags) => {
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={<Tag className="mt-[5px] size-4 text-gray-500" />}
searchPlaceholder="Search or add tags..."
shortcutHint="T"
Expand All @@ -180,6 +193,8 @@ export function TagSelect() {
onCreate={(search) => createTag(search)}
open={isOpen}
onOpenChange={setIsOpen}
onSearchChange={setSearch}
shouldFilter={!useAsync}
matchTriggerWidth
>
{selectedTags.length > 0 ? (
Expand All @@ -193,6 +208,8 @@ export function TagSelect() {
/>
))}
</div>
) : loadingTags && availableTags === undefined && tags.length ? (
<div className="my-px h-6 w-1/4 animate-pulse rounded bg-gray-200" />
) : (
<span className="my-px block py-0.5">Select tags...</span>
)}
Expand Down

0 comments on commit c0a369d

Please sign in to comment.