feat: initial tags

This commit is contained in:
Catalin Pit
2026-07-01 10:10:50 +03:00
parent 562d78e2d7
commit d20d9595c9
44 changed files with 1649 additions and 19 deletions
+24
View File
@@ -0,0 +1,24 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type TagBadgeProps = {
tag: Pick<TTagLite, 'name'>;
className?: string;
children?: ReactNode;
};
export const TagBadge = ({ tag, className, children }: TagBadgeProps) => {
return (
<span
className={cn(
'inline-flex items-center rounded-md px-2 py-0.5 font-medium text-xs ring-1 ring-inset',
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20',
className,
)}
>
{tag.name}
{children}
</span>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { TagType } from '@documenso/lib/types/tag-type';
import { trpc } from '@documenso/trpc/react';
import { Trans } from '@lingui/react/macro';
import { TagIcon } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { TagBadge } from './tag-badge';
export type TagFilterProps = {
type: (typeof TagType)[keyof typeof TagType];
};
export const TagFilter = ({ type }: TagFilterProps) => {
const [searchParams] = useSearchParams();
const [, setSearchParams] = useSearchParams();
const { data: tags } = trpc.tag.findTags.useQuery({
type,
perPage: 100,
});
const selectedTagIds = searchParams.get('tagIds')?.split(',').filter(Boolean) ?? [];
const toggleTag = (tagId: string) => {
const newTagIds = selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId];
setSearchParams(
(prev) => {
if (newTagIds.length > 0) {
prev.set('tagIds', newTagIds.join(','));
} else {
prev.delete('tagIds');
}
return prev;
},
{ preventScrollReset: true },
);
};
if (!tags || tags.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<TagIcon className="h-4 w-4" />
<Trans>Tags</Trans>
{selectedTagIds.length > 0 && (
<span className="ml-1 rounded-full bg-primary px-1.5 py-0.5 text-primary-foreground text-xs">
{selectedTagIds.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2">
{tags.data.map((tag) => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
>
<Checkbox checked={selectedTagIds.includes(tag.id)} onCheckedChange={() => toggleTag(tag.id)} />
<TagBadge tag={tag} />
</label>
))}
</div>
</PopoverContent>
</Popover>
);
};
+162
View File
@@ -0,0 +1,162 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import type { TagType } from '@documenso/lib/types/tag-type';
import { trpc } from '@documenso/trpc/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Input } from '../input';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { TagBadge } from './tag-badge';
export type TagInputProps = {
type: (typeof TagType)[keyof typeof TagType];
envelopeId: string;
assignedTags: TTagLite[];
className?: string;
};
export const TagInput = ({ type, envelopeId, assignedTags, className }: TagInputProps) => {
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const utils = trpc.useUtils();
const { data: tagsData } = trpc.tag.findTags.useQuery({
type,
perPage: 100,
});
const createTagMutation = trpc.tag.createTag.useMutation({
onSuccess: () => {
void utils.tag.findTags.invalidate();
},
});
const setEnvelopeTagsMutation = trpc.tag.setEnvelopeTags.useMutation({
onSuccess: () => {
void utils.tag.getEnvelopeTags.invalidate({ envelopeId });
},
});
const allTags = tagsData?.data ?? [];
const assignedTagIds = new Set(assignedTags.map((t) => t.id));
const availableTags = allTags.filter((t) => !assignedTagIds.has(t.id));
const filteredTags = search
? availableTags.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
: availableTags;
const exactMatch = allTags.find((t) => t.name.toLowerCase() === search.toLowerCase().trim());
const canCreate = search.trim().length > 0 && !exactMatch;
const handleAssignTag = (tagId: string) => {
setEnvelopeTagsMutation.mutate({
envelopeId,
tagIds: [...assignedTagIds, tagId],
});
setSearch('');
setOpen(false);
};
const handleUnassignTag = (tagId: string) => {
setEnvelopeTagsMutation.mutate({
envelopeId,
tagIds: assignedTags.filter((t) => t.id !== tagId).map((t) => t.id),
});
};
const handleCreateAndAssign = async () => {
const tag = await createTagMutation.mutateAsync({
name: search.trim(),
type,
});
handleAssignTag(tag.id);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && canCreate) {
e.preventDefault();
void handleCreateAndAssign();
}
};
const isMutating = createTagMutation.isPending || setEnvelopeTagsMutation.isPending;
return (
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
{assignedTags.map((tag) => (
<TagBadge key={tag.id} tag={tag} className="gap-1 pr-1">
<button
type="button"
onClick={() => handleUnassignTag(tag.id)}
className="ml-0.5 rounded-sm opacity-60 hover:opacity-100"
aria-label={t`Remove ${tag.name}`}
>
<XIcon className="h-3 w-3" />
</button>
</TagBadge>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-6 gap-1 px-2 text-xs" disabled={isMutating}>
<PlusIcon className="h-3 w-3" />
<Trans>Add tag</Trans>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="flex flex-col">
<Input
placeholder={t`Search or create tag...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="h-9 rounded-none border-0 border-b focus-visible:ring-0"
autoFocus
/>
<div className="max-h-48 overflow-y-auto p-1">
{filteredTags.length > 0 && (
<div className="flex flex-col gap-0.5">
{filteredTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => handleAssignTag(tag.id)}
className="flex items-center rounded-md px-2 py-1.5 text-left hover:bg-accent"
>
<TagBadge tag={tag} />
</button>
))}
</div>
)}
{canCreate && (
<button
type="button"
onClick={() => void handleCreateAndAssign()}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
>
<PlusIcon className="h-4 w-4" />
<span>
<Trans>Create "{search.trim()}"</Trans>
</span>
</button>
)}
{filteredTags.length === 0 && !canCreate && (
<div className="px-2 py-3 text-muted-foreground text-sm">
<Trans>No tags found</Trans>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
+32
View File
@@ -0,0 +1,32 @@
import type { TTagLite } from '@documenso/lib/types/tag';
import { Link } from 'react-router';
import { TagBadge } from './tag-badge';
export type TagListProps = {
tags: TTagLite[];
maxVisible?: number;
getTagHref?: (tag: TTagLite) => string;
};
export const TagList = ({ tags, maxVisible = 3, getTagHref }: TagListProps) => {
if (tags.length === 0) {
return null;
}
return (
<div className="flex max-w-[12rem] flex-wrap gap-1">
{tags.slice(0, maxVisible).map((tag) =>
getTagHref ? (
<Link key={tag.id} to={getTagHref(tag)}>
<TagBadge tag={tag} />
</Link>
) : (
<TagBadge key={tag.id} tag={tag} />
),
)}
{tags.length > maxVisible && <span className="text-muted-foreground text-xs">+{tags.length - maxVisible}</span>}
</div>
);
};