mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
feat: initial tags
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user