Compare commits

..

1 Commits

Author SHA1 Message Date
Catalin Pit d20d9595c9 feat: initial tags 2026-07-01 10:10:50 +03:00
49 changed files with 1655 additions and 27 deletions
@@ -0,0 +1,382 @@
---
date: 2026-06-29
title: Document Template Tags
---
## Problem
Users currently organise documents and templates using **folders** (a hierarchical, single-parent structure). There is no way to apply **flat, cross-cutting labels** (tags) to envelopes — e.g. "urgent", "invoice", "contract", "HR". This makes it hard to filter, group, and find documents/templates across folder boundaries.
Tags complement folders: a folder answers "where is this?", a tag answers "what kind is this?". An envelope can be in exactly one folder but should be able to carry many tags.
## Goals
1. Allow users to create, update, and delete tags scoped to their team.
2. Allow users to assign one or more tags to a document or template.
3. Allow users to filter the documents and templates list pages by tag(s).
4. Display tags on document/template table rows and detail pages.
5. Expose tag management and filtering through tRPC routes (and optionally the V1 public API).
6. Follow the existing **folder** feature as the architectural blueprint so the codebase stays consistent.
## Design Decisions
These decisions are informed by the existing `Folder` feature and codebase conventions:
1. **Tags are team-scoped** — Just like `Folder`, every `Tag` belongs to a `teamId` and a `userId` (creator). This matches `buildTeamWhereQuery` access patterns used throughout the lib layer.
2. **Tags are type-specific (DOCUMENT / TEMPLATE)** — Following the `FolderType` enum pattern (`DOCUMENT` / `TEMPLATE`). A `TagType` enum mirrors this so document tags and template tags are managed separately, consistent with how folders are split (`documents.folders._index.tsx` vs `templates.folders._index.tsx`). *Alternative considered: shared tags with no type — rejected for consistency with folders and to keep the UI clean.*
3. **Many-to-many via a join table (`EnvelopeTag`)** — Unlike folders (single `folderId` on `Envelope`), an envelope can have many tags and a tag can be on many envelopes. A join table is required.
4. **Optional `color` field** — A nullable hex color string (`#RRGGBB`) for visual distinction in the UI. Optional so it's not a breaking concern if omitted.
5. **Unique constraint `(teamId, name, type)`** — Prevents duplicate tag names within a team for a given type. Names are case-insensitive-normalised on write.
6. **Filtering semantics: OR (any of)** — When filtering by multiple tags, return envelopes that have **any** of the selected tags. This is the most common tag-filter UX. *Alternative considered: AND (must have all) — noted as a future toggle if needed.*
7. **Assigning tags replaces the full set** — The `setEnvelopeTags` operation takes an array of tag IDs and sets the envelope's tags to exactly that set (add/remove diff). This is simpler and less error-prone than individual add/remove operations and matches how form state typically works.
8. **Inline tag creation during assignment** — The assignment UI allows creating a new tag on-the-fly (autocomplete + create), similar to common tag-input UX. The `setEnvelopeTags` route will accept either an existing `tagId` or a `{ name, color }` to create-and-assign in one step.
9. **`deletedAt` is not needed for tags** — Unlike envelopes, tags are lightweight metadata. Deleting a tag cascades to remove the join-table rows (`onDelete: Cascade`). Envelopes are unaffected.
10. **V1 public API is a separate, optional phase** — Tag CRUD + filtering is added to tRPC first. V1 API endpoints can follow the same pattern used for folders (`packages/api/v1/`) but are deferred to a later phase to keep this change focused.
## Scope
This plan touches four layers: Prisma schema, lib (server-only), tRPC, and the Remix UI. New files are created for the tag lib functions and tag-router; existing files are extended for filtering and display.
| Layer | New files | Modified files |
| ----- | --------- | -------------- |
| Prisma | schema migration | `packages/prisma/schema.prisma` |
| Lib | `packages/lib/server-only/tag/*` (6 files) | `find-documents.ts`, `find-templates.ts` |
| tRPC | `packages/trpc/server/tag-router/*` (2 files), root router registration | `document-router/find-documents.types.ts`, `template-router` find types, root `trpc.ts` |
| UI | tag primitives + filter component | documents/templates list pages, tables, edit pages |
| V1 API (deferred) | — | — |
## Changes
### 1. Database Schema — `packages/prisma/schema.prisma`
Add a `TagType` enum and `Tag` model, plus an `EnvelopeTag` join table:
```prisma
enum TagType {
DOCUMENT
TEMPLATE
}
model Tag {
id String @id @default(cuid())
name String
color String?
type TagType
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
envelopes EnvelopeTag[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([teamId, name, type])
@@index([teamId])
@@index([teamId, type])
}
model EnvelopeTag {
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
assignedBy Int
assignedByUser User @relation(fields: [assignedBy], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
@@id([envelopeId, tagId])
@@index([tagId])
}
```
Add the back-relations to `Envelope` and `User` and `Team`:
```prisma
// On Envelope model, add:
tags EnvelopeTag[]
// On User model, add:
tags Tag[]
envelopeTags EnvelopeTag[]
// On Team model, add:
tags Tag[]
```
**Migration:** Run `npx prisma migrate dev --name add_document_template_tags` (or the project's equivalent migration command). The migration creates both tables, indexes, and the unique constraint.
### 2. Lib Layer — `packages/lib/server-only/tag/`
Create a new `tag/` directory mirroring the `folder/` directory structure. Each file follows the existing pattern: import `prisma`, use `AppError`/`AppErrorCode`, use `buildTeamWhereQuery` for team access, and `getTeamById` / `getMemberRoles` for role-based visibility.
#### `create-tag.ts`
```ts
export interface CreateTagOptions {
userId: number;
teamId: number;
name: string;
color?: string;
type: TTagType; // DOCUMENT | TEMPLATE
}
```
- Validates team access via `getTeamSettings`.
- Normalises `name` (trim, collapse whitespace).
- Throws `AppError(AppErrorCode.CONFLICT)` on duplicate `(teamId, name, type)` (caught from Prisma unique violation or pre-checked).
- Returns the created `Tag`.
#### `find-tags.ts`
```ts
export interface FindTagsOptions {
userId: number;
teamId: number;
type?: TTagType;
query?: string;
page?: number;
perPage?: number;
}
```
- Paginated list of tags for a team, optionally filtered by type and a name search query.
- Uses `buildTeamWhereQuery` for access control.
- Returns `FindResultResponse<Tag>`.
#### `update-tag.ts`
```ts
export interface UpdateTagOptions {
userId: number;
teamId: number;
tagId: string;
data: { name?: string; color?: string | null };
}
```
- Verifies tag belongs to the user's team.
- Updates name and/or color.
- Re-validates uniqueness if name changes.
#### `delete-tag.ts`
```ts
export interface DeleteTagOptions {
userId: number;
teamId: number;
tagId: string;
}
```
- Verifies ownership/team access.
- `prisma.tag.delete` — cascades to `EnvelopeTag` rows automatically.
#### `set-envelope-tags.ts`
```ts
export interface SetEnvelopeTagsOptions {
userId: number;
teamId: number;
envelopeId: string;
tagIds: string[];
}
```
- Fetches the envelope, verifies access (team + visibility, same checks as folder operations).
- Verifies all `tagIds` belong to the same team and match the envelope's type (DOCUMENT tags for documents, TEMPLATE tags for templates).
- Uses a Prisma transaction to diff: delete `EnvelopeTag` rows not in the new set, insert missing ones.
- Returns the updated list of tags on the envelope.
#### `get-envelope-tags.ts`
```ts
export interface GetEnvelopeTagsOptions {
userId: number;
teamId: number;
envelopeId: string;
}
```
- Returns all tags assigned to an envelope (with access check).
#### `types/tag-type.ts` — `packages/lib/types/tag-type.ts`
```ts
import { z } from 'zod';
export const ZTagTypeSchema = z.enum(['DOCUMENT', 'TEMPLATE']);
export type TTagType = z.infer<typeof ZTagTypeSchema>;
```
(Mirrors `packages/lib/types/folder-type.ts`.)
### 3. Filtering — Modify `find-documents.ts` and `find-templates.ts`
#### `find-documents.ts` (Kysely-based)
- Add `tagIds?: string[]` to `FindDocumentsOptions`.
- When `tagIds` is non-empty, add an `EXISTS` subquery to the WHERE clause:
```ts
eb.exists(
eb.selectFrom('EnvelopeTag')
.whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id')
.where('EnvelopeTag.tagId', 'in', sql.join(tagIds.map(sql.lit)))
.select(sql.lit(1).as('one'))
)
```
This implements **OR (any of)** semantics — the envelope matches if it has at least one of the selected tags.
- Hydration step (`prisma.envelope.findMany`) should `include: { tags: { include: { tag: true } } }` so tags are returned with each document.
#### `find-templates.ts` (Prisma-based)
- Add `tagIds?: string[]` to `FindTemplatesOptions`.
- Add to the `where` clause:
```ts
tagIds?.length ? { tags: { some: { tagId: { in: tagIds } } } } : undefined
```
- Add `tags: { include: { tag: true } }` to `templateInclude`.
### 4. tRPC Layer — `packages/trpc/server/tag-router/`
Create `tag-router/router.ts` and `tag-router/schema.ts`, mirroring `folder-router/`.
#### `tag-router/schema.ts`
```ts
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
import { ZTagTypeSchema } from '@documenso/lib/types/tag-type';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZTagSchema = TagSchema.pick({
id: true, name: true, color: true, type: true,
teamId: true, userId: true, createdAt: true, updatedAt: true,
});
export const ZCreateTagRequestSchema = z.object({
name: z.string().min(1).max(50),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
type: ZTagTypeSchema,
});
export const ZCreateTagResponseSchema = ZTagSchema;
export const ZUpdateTagRequestSchema = z.object({
tagId: z.string(),
data: z.object({
name: z.string().min(1).max(50).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
}),
});
export const ZDeleteTagRequestSchema = z.object({ tagId: z.string() });
export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({
type: ZTagTypeSchema.optional(),
query: z.string().optional(),
});
export const ZFindTagsResponseSchema = ZFindResultResponse.extend({
data: z.array(ZTagSchema),
});
export const ZSetEnvelopeTagsRequestSchema = z.object({
envelopeId: z.string(),
tagIds: z.array(z.string()),
});
export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
```
#### `tag-router/router.ts`
Routes following the folder-router conventions (`authenticatedProcedure`, OpenAPI meta with `tags: ['Tag']`, GET/POST only):
| Route name | Method | Path | Description |
| ---------- | ------ | ---- | ----------- |
| `findTags` | GET | `/tag` | Find tags for the current team |
| `createTag` | POST | `/tag/create` | Create a new tag |
| `updateTag` | POST | `/tag/update` | Update a tag's name/color |
| `deleteTag` | POST | `/tag/delete` | Delete a tag (cascades to assignments) |
| `setEnvelopeTags` | POST | `/tag/assign` | Set the full tag set on an envelope |
Each route deconstructs `input` on its own line, uses `ctx.teamId` and `ctx.user.id`, and logs via `ctx.logger.info`.
#### Register the router
In the root tRPC router file (where `folderRouter` is registered), add:
```ts
import { tagRouter } from './tag-router/router';
// ...
tag: tagRouter,
```
#### Update find-documents / find-templates request schemas
- `document-router/find-documents.types.ts`: add `tagIds: z.array(z.string()).optional()` to `ZFindDocumentsRequestSchema`.
- `template-router` find types: add `tagIds: z.array(z.string()).optional()`.
- `document-router/find-documents-internal.types.ts`: add `tagIds` so the UI can filter.
### 5. UI Layer — Remix app
#### New primitives (`packages/ui/primitives/tag/`)
- `tag-badge.tsx` — a small coloured pill displaying a tag name (uses `color` if present, falls back to a default Tailwind colour).
- `tag-input.tsx` — an autocomplete multi-select tag input with inline creation (used in edit pages and dialogs). Renders existing tags as removable badges; typing filters suggestions; pressing Enter or selecting creates/assigns. Uses the existing `Command` (cmdk) primitive if available, otherwise a Popover + input pattern.
- `tag-filter.tsx` — a multi-select dropdown for the list pages that adds `tagIds` to the URL search params.
#### Documents list page — `t.$teamUrl+/documents._index.tsx`
- Add `tagIds` to `ZSearchParamsSchema` (parse from comma-separated query string).
- Pass `tagIds` into the `findDocuments` / `findDocumentsInternal` tRPC query.
- Render `<TagFilter type="DOCUMENT" />` in the filter toolbar alongside the period selector and search.
- Fetch tags via `trpc.tag.findTags.useQuery({ type: 'DOCUMENT' })` for the filter options.
#### Templates list page — `t.$teamUrl+/templates._index.tsx`
- Same changes as documents, with `type: 'TEMPLATE'`.
#### Tables — `documents-table.tsx` and `templates-table.tsx`
- Add a "Tags" column that renders `<TagBadge>` for each tag on the row.
- The row data already includes `tags` (from the hydration `include` added in step 3).
#### Document/template detail pages
- `documents.$id._index.tsx` / `templates.$id._index.tsx`: display assigned tags as badges.
- `documents.$id.edit.tsx` / `templates.$id.edit.tsx`: add a `<TagInput>` field that calls `trpc.tag.setEnvelopeTags.mutate` on change.
#### Tag management
- A lightweight management UI (rename/delete tags) can be added to team settings or as a small popover from the `<TagFilter>`. This can be a minimal first iteration (create via the tag-input inline, delete/rename via a settings page later).
### 6. V1 Public API (Deferred — Phase 2)
Once the tRPC + UI layer is stable, add to `packages/api/v1/`:
- `GET /api/v1/tags` — list tags (with `type` query param).
- `POST /api/v1/tags` — create tag.
- `POST /api/v1/tags/assign` — assign tags to an envelope.
- Add `tags` array to the document/template response schemas.
- Add `tagIds` filter to `GET /api/v1/documents`.
This mirrors exactly how folder support was added to the V1 API (see the `wild-teal-wind-add-folder-support-to-v1-api` plan).
## Implementation Order
1. **Prisma schema + migration** — add models, run migration, regenerate client (`zod-prisma-types` will generate `TagSchema`).
2. **Lib layer** — create `tag/` functions; extend `find-documents.ts` and `find-templates.ts` with `tagIds` filter + hydration.
3. **tRPC layer** — create `tag-router`, register it, extend find request schemas.
4. **UI primitives** — `TagBadge`, `TagInput`, `TagFilter`.
5. **List pages** — wire up `TagFilter` + tags column on documents and templates.
6. **Detail/edit pages** — display + assign tags.
7. **E2E tests** — tag CRUD, assign, filter.
8. *(Deferred)* V1 API endpoints.
## Testing
E2E tests in `packages/app-tests` following existing Playwright patterns:
1. Create a tag from the documents tag filter — verify it appears in the list.
2. Assign tags to a document from the edit page — verify badges appear on the table row.
3. Filter documents by a tag — verify only tagged documents show.
4. Filter by multiple tags (OR) — verify union of results.
5. Delete a tag — verify it's removed from all assigned documents.
6. Repeat 15 for templates.
7. Verify team scoping — tags from team A are not visible in team B.
8. Verify a DOCUMENT tag cannot be assigned to a TEMPLATE and vice versa.
## Migration & Compatibility Notes
- The new `Tag` and `EnvelopeTag` tables are purely additive — no existing columns are modified or removed.
- The `tags` relation added to `Envelope` is additive; existing queries that don't `include` it are unaffected.
- The `tagIds` filter is optional in all find functions; when omitted, behaviour is identical to today.
- No breaking changes to existing tRPC routes or V1 API responses (tags are added as new optional fields).
- The `zod-prisma-types` generator (already configured in `schema.prisma`) will auto-generate `TagSchema` for use in `tag-router/schema.ts`, same as `FolderSchema` is used in `folder-router/schema.ts`.
## Open Questions
1. **Tag name uniqueness scope** — Should uniqueness be `(teamId, name, type)` (case-insensitive) or should we allow duplicates and dedupe in the UI? **Recommendation:** enforce in DB (decision #5).
2. **Max tags per envelope** — Should there be a limit (e.g. 10)? **Recommendation:** no hard limit initially; can add a limit later.
3. **Bulk tag assignment** — Should the bulk action bar on the documents page support "add tag to all selected"? **Recommendation:** yes, as a follow-up; the `setEnvelopeTags` lib function can be extended to accept multiple envelope IDs, or a separate `bulkAssignTags` function can be added (mirroring `bulk-move-envelopes`).
4. **Tag colors** — Should we ship a fixed palette picker or allow free-form hex? **Recommendation:** fixed palette of ~8 colours for v1, stored as hex in the `color` column.
+2
View File
@@ -74,3 +74,5 @@ tmp/
# opencode
.opencode/package-lock.json
SUPPORT_KNOWLEDGE_BASE.md
@@ -0,0 +1,27 @@
import type { TagType } from '@documenso/lib/types/tag-type';
import { trpc } from '@documenso/trpc/react';
import { TagInput } from '@documenso/ui/primitives/tag/tag-input';
import { Trans } from '@lingui/react/macro';
import { TagsIcon } from 'lucide-react';
export type EnvelopeTagsSectionProps = {
envelopeId: string;
type: (typeof TagType)[keyof typeof TagType];
};
export const EnvelopeTagsSection = ({ envelopeId, type }: EnvelopeTagsSectionProps) => {
const { data: assignedTags } = trpc.tag.getEnvelopeTags.useQuery({ envelopeId });
return (
<section className="flex flex-col rounded-xl border border-border bg-widget text-foreground dark:bg-background">
<h1 className="flex items-center gap-2 px-4 py-3 font-medium">
<TagsIcon className="h-4 w-4" />
<Trans>Tags</Trans>
</h1>
<div className="border-t px-4 py-3">
<TagInput type={type} envelopeId={envelopeId} assignedTags={assignedTags ?? []} />
</div>
</section>
);
};
@@ -10,6 +10,7 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { TagList } from '@documenso/ui/primitives/tag/tag-list';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
@@ -49,6 +50,8 @@ export const DocumentsTable = ({
const { _, i18n } = useLingui();
const team = useCurrentTeam();
const documentsPath = formatDocumentsPath(team?.url ?? '');
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@@ -96,6 +99,11 @@ export const DocumentsTable = ({
header: _(msg`Sender`),
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
},
{
header: _(msg`Tags`),
cell: ({ row }) => <TagList tags={row.original.tags} getTagHref={(tag) => `${documentsPath}/tag/${tag.id}`} />,
size: 160,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
@@ -10,6 +10,7 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { TagList } from '@documenso/ui/primitives/tag/tag-list';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -32,6 +33,7 @@ type TemplatesTableProps = {
documentRootPath: string;
templateRootPath: string;
enableSelection?: boolean;
enableTagLinks?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
@@ -45,6 +47,7 @@ export const TemplatesTable = ({
documentRootPath,
templateRootPath,
enableSelection,
enableTagLinks = true,
rowSelection,
onRowSelectionChange,
}: TemplatesTableProps) => {
@@ -109,6 +112,16 @@ export const TemplatesTable = ({
</Link>
),
},
{
header: _(msg`Tags`),
cell: ({ row }) => (
<TagList
tags={row.original.tags}
getTagHref={enableTagLinks ? (tag) => `${templateRootPath}/tag/${tag.id}` : undefined}
/>
),
size: 160,
},
{
header: () => (
<div className="flex flex-row items-center">
@@ -2,6 +2,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { TagType } from '@documenso/lib/types/tag-type';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -34,6 +35,7 @@ import {
} from '~/components/general/document/document-status';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
@@ -252,6 +254,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{/* Document information section. */}
<DocumentPageViewInformation envelope={envelope} userId={user.id} />
{/* Tags section. */}
<EnvelopeTagsSection envelopeId={envelope.id} type={TagType.DOCUMENT} />
{/* Recipients section. */}
<DocumentPageViewRecipients envelope={envelope} documentRootPath={documentRootPath} />
@@ -2,6 +2,7 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { TagType } from '@documenso/lib/types/tag-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -12,9 +13,11 @@ import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/docu
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TagFilter } from '@documenso/ui/primitives/tag/tag-filter';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -46,13 +49,18 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
tagIds: z
.string()
.optional()
.catch(undefined)
.transform((val) => val?.split(',').filter(Boolean)),
});
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const { folderId, tagId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -85,10 +93,15 @@ export default function DocumentsPage() {
[searchParams],
);
// Route param tagId takes priority over URL search param tagIds.
const effectiveTagIds = tagId ? [tagId] : findDocumentSearchParams.tagIds;
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
folderId,
includeAllFolders: tagId !== undefined,
tagIds: effectiveTagIds,
},
{
...SKIP_QUERY_BATCH_META,
@@ -114,7 +127,9 @@ export default function DocumentsPage() {
let path = formatDocumentsPath(team.url);
if (folderId) {
if (tagId) {
path += `/tag/${tagId}`;
} else if (folderId) {
path += `/f/${folderId}`;
}
@@ -134,7 +149,14 @@ export default function DocumentsPage() {
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
{tagId && (
<Link to={documentsPath} className="mb-4 flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>All documents</Trans>
</Link>
)}
{!tagId && <FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />}
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
@@ -184,6 +206,8 @@ export default function DocumentsPage() {
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{!tagId && <TagFilter type={TagType.DOCUMENT} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
@@ -0,0 +1,5 @@
import DocumentPage, { meta } from './documents._index';
export { meta };
export default DocumentPage;
@@ -1,6 +1,7 @@
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { TagType } from '@documenso/lib/types/tag-type';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -21,6 +22,7 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
@@ -271,6 +273,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{/* Template information section. */}
<TemplatePageViewInformation template={envelope} userId={user.id} />
{/* Tags section. */}
<EnvelopeTagsSection envelopeId={envelope.id} type={TagType.TEMPLATE} />
{/* Recipients section. */}
<TemplatePageViewRecipients
recipients={envelope.recipients}
@@ -1,19 +1,21 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { FolderType } from '@documenso/lib/types/folder-type';
import { TagType } from '@documenso/lib/types/tag-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TagFilter } from '@documenso/ui/primitives/tag/tag-filter';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { Bird, ChevronLeft } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { Link, useParams, useSearchParams } from 'react-router';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@@ -36,16 +38,20 @@ export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const { folderId, tagId } = useParams();
const [searchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const urlTagIds = searchParams.get('tagIds')?.split(',').filter(Boolean) ?? [];
// Route param tagId takes priority over URL search param tagIds.
const effectiveTagIds = tagId ? [tagId] : urlTagIds;
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const isOrgView = !tagId && view === 'organisation';
const showOrgTab = !tagId && organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
@@ -63,6 +69,8 @@ export default function TemplatesPage() {
page,
perPage,
folderId,
includeAllFolders: tagId !== undefined,
tagIds: effectiveTagIds.length > 0 ? effectiveTagIds : undefined,
},
{
enabled: !isOrgView,
@@ -92,7 +100,14 @@ export default function TemplatesPage() {
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
{tagId && (
<Link to={templateRootPath} className="mb-4 flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>All templates</Trans>
</Link>
)}
{!isOrgView && !tagId && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
<div className="mt-8">
<div className="flex flex-row items-center">
@@ -129,6 +144,12 @@ export default function TemplatesPage() {
</div>
)}
{!isOrgView && !tagId && (
<div className="mt-6">
<TagFilter type={TagType.TEMPLATE} />
</div>
)}
<div className="mt-8">
{activeQuery.data && activeQuery.data.count === 0 ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
@@ -156,6 +177,7 @@ export default function TemplatesPage() {
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
enableSelection={!isOrgView}
enableTagLinks={!isOrgView}
rowSelection={isOrgView ? {} : rowSelection}
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
/>
@@ -0,0 +1,5 @@
import TemplatePage, { meta } from './templates._index';
export { meta };
export default TemplatePage;
@@ -83,17 +83,15 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
// Handle hard or soft deleting the actual document if user has permission.
if (hasDeleteAccess) {
const updatedEnvelope = await handleDocumentOwnerDelete({
await handleDocumentOwnerDelete({
envelope,
user,
requestMetadata,
});
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
@@ -36,6 +36,8 @@ export type FindDocumentsOptions = {
senderIds?: number[];
query?: string;
folderId?: string;
includeAllFolders?: boolean;
tagIds?: string[];
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
@@ -102,6 +104,22 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
.select(sql.lit(1).as('one')),
);
export const applyEnvelopeTagFilter = (qb: EnvelopeQueryBuilder, tagIds?: string[]) => {
if (!tagIds || tagIds.length === 0) {
return qb;
}
return qb.where((eb) =>
eb.exists(
eb
.selectFrom('EnvelopeTag')
.whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id')
.where('EnvelopeTag.tagId', 'in', tagIds)
.select(sql.lit(1).as('one')),
),
);
};
export const findDocuments = async ({
userId,
teamId,
@@ -115,6 +133,8 @@ export const findDocuments = async ({
senderIds,
query = '',
folderId,
includeAllFolders = false,
tagIds,
useWindowedCount = true,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -147,8 +167,12 @@ export const findDocuments = async ({
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
// Folder filter
qb =
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
if (!includeAllFolders) {
qb =
folderId !== undefined
? qb.where('Envelope.folderId', '=', folderId)
: qb.where('Envelope.folderId', 'is', null);
}
// Period filter
if (period) {
@@ -199,6 +223,9 @@ export const findDocuments = async ({
);
}
// Tag filter (OR semantics — envelope must have at least one of the selected tags)
qb = applyEnvelopeTagFilter(qb, tagIds);
return qb;
};
@@ -520,6 +547,7 @@ export const findDocuments = async ({
envelopeItems: {
select: { id: true, envelopeId: true, title: true, order: true },
},
tags: { include: { tag: true } },
},
});
+22 -4
View File
@@ -1,4 +1,4 @@
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { applyEnvelopeTagFilter, type PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -57,7 +57,9 @@ export type GetStatsInput = {
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
includeAllFolders?: boolean;
senderIds?: number[];
tagIds?: string[];
};
/**
@@ -80,7 +82,16 @@ const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
};
export const getStats = async ({ userId, teamId, period, search = '', folderId, senderIds }: GetStatsInput) => {
export const getStats = async ({
userId,
teamId,
period,
search = '',
folderId,
includeAllFolders = false,
senderIds,
tagIds,
}: GetStatsInput) => {
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
@@ -105,8 +116,12 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
// Folder filter
qb =
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
if (!includeAllFolders) {
qb =
folderId !== undefined
? qb.where('Envelope.folderId', '=', folderId)
: qb.where('Envelope.folderId', 'is', null);
}
// Period filter
if (period) {
@@ -121,6 +136,9 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
qb = qb.where('Envelope.userId', 'in', senderIds);
}
// Tag filter (OR semantics — envelope must have at least one of the selected tags)
qb = applyEnvelopeTagFilter(qb, tagIds);
// Search filter
if (hasSearch) {
qb = qb.where(({ or, eb }) =>
@@ -37,9 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -10,8 +10,8 @@ import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export interface DuplicateEnvelopeOptions {
@@ -0,0 +1,51 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TTagType } from '../../types/tag-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export type CreateTagOptions = {
userId: number;
teamId: number;
name: string;
type: TTagType;
};
export const createTag = async ({ userId, teamId, name, type }: CreateTagOptions) => {
// This indirectly verifies whether the user has access to the team.
await getTeamSettings({ userId, teamId });
const normalizedName = name.trim().replace(/\s+/g, ' ');
const normalizedNameKey = normalizedName.toLowerCase();
if (!normalizedName) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Tag name cannot be empty',
});
}
const existing = await prisma.tag.findFirst({
where: {
teamId,
normalizedName: normalizedNameKey,
type,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (existing) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'A tag with this name already exists for this type',
});
}
return await prisma.tag.create({
data: {
name: normalizedName,
normalizedName: normalizedNameKey,
type,
teamId,
},
});
};
@@ -0,0 +1,34 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export type DeleteTagOptions = {
userId: number;
teamId: number;
tagId: string;
};
export const deleteTag = async ({ userId, teamId, tagId }: DeleteTagOptions) => {
await getTeamById({ userId, teamId });
const tag = await prisma.tag.findFirst({
where: {
id: tagId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!tag) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Tag not found',
});
}
return await prisma.tag.delete({
where: {
id: tag.id,
},
});
};
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import type { FindResultResponse } from '../../types/search-params';
import type { TTagType } from '../../types/tag-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export type FindTagsOptions = {
userId: number;
teamId: number;
type?: TTagType;
query?: string;
page?: number;
perPage?: number;
};
export const findTags = async ({ userId, teamId, type, query, page = 1, perPage = 10 }: FindTagsOptions) => {
await getTeamById({ userId, teamId });
const whereClause: Prisma.TagWhereInput = {
team: buildTeamWhereQuery({ teamId, userId }),
type,
name: query ? { contains: query, mode: 'insensitive' } : undefined,
};
const [data, count] = await Promise.all([
prisma.tag.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.tag.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -0,0 +1,44 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { mapEnvelopeTagsToTags } from '../../utils/tags';
import { getTeamById } from '../team/get-team';
export type GetEnvelopeTagsOptions = {
userId: number;
teamId: number;
envelopeId: string;
};
export const getEnvelopeTags = async ({ userId, teamId, envelopeId }: GetEnvelopeTagsOptions) => {
const team = await getTeamById({ userId, teamId });
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
OR: [
{ userId },
{
teamId: team.id,
visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] },
},
],
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const envelopeTags = await prisma.envelopeTag.findMany({
where: { envelopeId },
include: {
tag: true,
},
});
return mapEnvelopeTagsToTags(envelopeTags);
};
@@ -0,0 +1,98 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { TagType } from '../../types/tag-type';
import { mapEnvelopeTagsToTags } from '../../utils/tags';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export type SetEnvelopeTagsOptions = {
userId: number;
teamId: number;
envelopeId: string;
tagIds: string[];
};
export const setEnvelopeTags = async ({ userId, teamId, envelopeId, tagIds }: SetEnvelopeTagsOptions) => {
const team = await getTeamById({ userId, teamId });
// Verify the envelope exists and the user has access.
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
OR: [
{ userId },
{
teamId: team.id,
visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] },
},
],
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
// Determine the expected tag type based on the envelope type.
const expectedTagType = envelope.type === EnvelopeType.DOCUMENT ? TagType.DOCUMENT : TagType.TEMPLATE;
// Verify all tagIds belong to the same team and match the envelope type.
if (tagIds.length > 0) {
const tags = await prisma.tag.findMany({
where: {
id: { in: tagIds },
team: buildTeamWhereQuery({ teamId, userId }),
type: expectedTagType,
},
});
if (tags.length !== tagIds.length) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'One or more tags are invalid or do not match the envelope type',
});
}
}
await prisma.$transaction(async (tx) => {
// Delete EnvelopeTag rows not in the new set.
await tx.envelopeTag.deleteMany({
where: {
envelopeId,
tagId: { notIn: tagIds },
},
});
// Fetch current assignments to find which ones need to be created.
const existing = await tx.envelopeTag.findMany({
where: { envelopeId },
select: { tagId: true },
});
const existingTagIds = new Set(existing.map((et) => et.tagId));
const toCreate = tagIds.filter((tagId) => !existingTagIds.has(tagId));
if (toCreate.length > 0) {
await tx.envelopeTag.createMany({
data: toCreate.map((tagId) => ({
envelopeId,
tagId,
assignedBy: userId,
})),
});
}
});
const tags = await prisma.envelopeTag.findMany({
where: { envelopeId },
include: {
tag: true,
},
});
return mapEnvelopeTagsToTags(tags);
};
@@ -0,0 +1,71 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export type UpdateTagOptions = {
userId: number;
teamId: number;
tagId: string;
data: {
name?: string;
};
};
export const updateTag = async ({ userId, teamId, tagId, data }: UpdateTagOptions) => {
const { name } = data;
await getTeamById({ userId, teamId });
const tag = await prisma.tag.findFirst({
where: {
id: tagId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!tag) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Tag not found',
});
}
if (name !== undefined) {
const normalizedName = name.trim().replace(/\s+/g, ' ');
const normalizedNameKey = normalizedName.toLowerCase();
if (!normalizedName) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Tag name cannot be empty',
});
}
if (normalizedNameKey !== tag.normalizedName) {
const existing = await prisma.tag.findFirst({
where: {
teamId,
normalizedName: normalizedNameKey,
type: tag.type,
id: { not: tagId },
},
});
if (existing) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'A tag with this name already exists for this type',
});
}
}
return await prisma.tag.update({
where: { id: tagId },
data: {
name: normalizedName,
normalizedName: normalizedNameKey,
},
});
}
return tag;
};
@@ -51,8 +51,8 @@ import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
@@ -58,6 +58,11 @@ export const findOrganisationTemplates = async ({
enabled: true,
},
},
tags: {
include: {
tag: true,
},
},
} as const;
const [data, count] = await Promise.all([
@@ -13,6 +13,8 @@ export type FindTemplatesOptions = {
page?: number;
perPage?: number;
folderId?: string;
includeAllFolders?: boolean;
tagIds?: string[];
};
export const findTemplates = async ({
@@ -22,6 +24,8 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
folderId,
includeAllFolders = false,
tagIds,
}: FindTemplatesOptions) => {
const { teamRole } = await getMemberRoles({
teamId,
@@ -46,7 +50,8 @@ export const findTemplates = async ({
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
...(includeAllFolders ? [] : [folderId ? { folderId } : { folderId: null }]),
...(tagIds && tagIds.length > 0 ? [{ tags: { some: { tagId: { in: tagIds } } } }] : []),
],
};
@@ -67,6 +72,11 @@ export const findTemplates = async ({
enabled: true,
},
},
tags: {
include: {
tag: true,
},
},
} as const;
const [data, count] = await Promise.all([
+2 -1
View File
@@ -6,9 +6,9 @@ import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSche
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
import { z } from 'zod';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
import { ZTagLiteSchema } from './tag';
/**
* The full document response schema.
@@ -169,6 +169,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
id: true,
url: true,
}).nullable(),
tags: ZTagLiteSchema.array(),
});
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;
+1 -1
View File
@@ -52,7 +52,7 @@ export const ZClaimFlagsSchema = z.object({
signingReminders: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
/**
* Controls whether an organisation is prevented from sending emails.
*
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const TagType = {
DOCUMENT: 'DOCUMENT',
TEMPLATE: 'TEMPLATE',
} as const;
export const ZTagTypeSchema = z.enum([TagType.DOCUMENT, TagType.TEMPLATE]);
export type TTagType = z.infer<typeof ZTagTypeSchema>;
+10
View File
@@ -0,0 +1,10 @@
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
import type { z } from 'zod';
export const ZTagLiteSchema = TagSchema.pick({
id: true,
name: true,
type: true,
});
export type TTagLite = z.infer<typeof ZTagLiteSchema>;
+2 -1
View File
@@ -6,9 +6,9 @@ import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { LegacyTemplateDirectLinkSchema, TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
import { z } from 'zod';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
import { ZTagLiteSchema } from './tag';
/**
* The full template response schema.
@@ -156,6 +156,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
}).nullable(),
// Backwards compatibility.
templateDocumentDataId: z.string().default(''),
tags: ZTagLiteSchema.array(),
});
export type TTemplateMany = z.infer<typeof ZTemplateManySchema>;
+3
View File
@@ -8,6 +8,7 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
import { SignatureLevel } from '../types/signature-level';
import { mapSecondaryIdToDocumentId } from './envelope';
import { mapRecipientToLegacyRecipient } from './recipients';
import { type EnvelopeTagWithTag, mapEnvelopeTagsToTags } from './tags';
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
@@ -109,6 +110,7 @@ type MapEnvelopeToDocumentManyOptions = Envelope & {
user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'>;
recipients: Recipient[];
tags?: EnvelopeTagWithTag<TDocumentMany['tags'][number]>[];
};
/**
@@ -150,5 +152,6 @@ export const mapEnvelopesToDocumentMany = (envelope: MapEnvelopeToDocumentManyOp
url: envelope.team.url,
},
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
tags: mapEnvelopeTagsToTags(envelope.tags),
};
};
+7
View File
@@ -0,0 +1,7 @@
export type EnvelopeTagWithTag<TTag> = {
tag: TTag;
};
export const mapEnvelopeTagsToTags = <TTag>(envelopeTags?: EnvelopeTagWithTag<TTag>[] | null) => {
return (envelopeTags ?? []).map((envelopeTag) => envelopeTag.tag);
};
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "TagType" AS ENUM ('DOCUMENT', 'TEMPLATE');
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"type" "TagType" NOT NULL,
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EnvelopeTag" (
"envelopeId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"assignedBy" INTEGER,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EnvelopeTag_pkey" PRIMARY KEY ("envelopeId", "tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_teamId_normalizedName_type_key" ON "Tag"("teamId", "normalizedName", "type");
-- CreateIndex
CREATE INDEX "Tag_teamId_idx" ON "Tag"("teamId");
-- CreateIndex
CREATE INDEX "Tag_teamId_type_idx" ON "Tag"("teamId", "type");
-- CreateIndex
CREATE INDEX "EnvelopeTag_tagId_idx" ON "EnvelopeTag"("tagId");
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_teamId_fkey"
FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_envelopeId_fkey"
FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_tagId_fkey"
FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE;
-- AddForeignKey
ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_assignedBy_fkey"
FOREIGN KEY ("assignedBy") REFERENCES "User"("id") ON DELETE SET NULL;
+39
View File
@@ -66,6 +66,8 @@ model User {
folders Folder[]
envelopes Envelope[]
envelopeTags EnvelopeTag[]
verificationTokens VerificationToken[]
apiTokens ApiToken[]
securityAuditLogs UserSecurityAuditLog[]
@@ -402,6 +404,40 @@ enum FolderType {
TEMPLATE
}
enum TagType {
DOCUMENT
TEMPLATE
}
model Tag {
id String @id @default(cuid())
name String
normalizedName String
type TagType
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
envelopes EnvelopeTag[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([teamId, normalizedName, type])
@@index([teamId])
@@index([teamId, type])
}
model EnvelopeTag {
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
assignedBy Int?
assignedByUser User? @relation(fields: [assignedBy], references: [id], onDelete: SetNull)
assignedAt DateTime @default(now())
@@id([envelopeId, tagId])
@@index([tagId])
}
model Folder {
id String @id @default(cuid())
name String
@@ -486,6 +522,8 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
tags EnvelopeTag[]
@@index([type])
@@index([status])
@@index([userId])
@@ -1052,6 +1090,7 @@ model Team {
envelopes Envelope[]
folders Folder[]
tags Tag[]
apiTokens ApiToken[]
webhooks Webhook[]
teamGroups TeamGroup[]
@@ -26,6 +26,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
senderIds,
folderId,
includeAllFolders,
tagIds,
} = input;
const [stats, documents] = await Promise.all([
@@ -35,7 +37,9 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
search: query,
folderId,
includeAllFolders,
senderIds,
tagIds,
}),
findDocuments({
userId: user.id,
@@ -49,6 +53,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure
period,
senderIds,
folderId,
includeAllFolders,
tagIds,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
}),
]);
@@ -10,6 +10,8 @@ export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.e
senderIds: z.array(z.number()).optional(),
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
folderId: z.string().optional(),
includeAllFolders: z.boolean().optional(),
tagIds: z.array(z.string()).optional(),
});
export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
@@ -11,7 +11,8 @@ export const findDocumentsRoute = authenticatedProcedure
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId, tagIds } =
input;
const documents = await findDocuments({
userId: user.id,
@@ -23,6 +24,7 @@ export const findDocumentsRoute = authenticatedProcedure
page,
perPage,
folderId,
tagIds,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
useWindowedCount: false,
});
@@ -20,6 +20,7 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
source: z.nativeEnum(DocumentSource).describe('Filter documents by how it was created.').optional(),
status: z.nativeEnum(DocumentStatus).describe('Filter documents by the current status').optional(),
folderId: z.string().describe('Filter documents by folder ID').optional(),
tagIds: z.array(z.string()).describe('Filter documents by tag IDs').optional(),
orderByColumn: z.enum(['createdAt']).optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'),
});
@@ -55,6 +55,11 @@ export const getDocumentsByIdsRoute = authenticatedProcedure
url: true,
},
},
tags: {
include: {
tag: true,
},
},
},
});
+2
View File
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
import { organisationRouter } from './organisation-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { tagRouter } from './tag-router/router';
import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
@@ -29,6 +30,7 @@ export const appRouter = router({
apiToken: apiTokenRouter,
team: teamRouter,
template: templateRouter,
tag: tagRouter,
webhook: webhookRouter,
embeddingPresign: embeddingPresignRouter,
});
+217
View File
@@ -0,0 +1,217 @@
import { createTag } from '@documenso/lib/server-only/tag/create-tag';
import { deleteTag } from '@documenso/lib/server-only/tag/delete-tag';
import { findTags } from '@documenso/lib/server-only/tag/find-tags';
import { getEnvelopeTags } from '@documenso/lib/server-only/tag/get-envelope-tags';
import { setEnvelopeTags } from '@documenso/lib/server-only/tag/set-envelope-tags';
import { updateTag } from '@documenso/lib/server-only/tag/update-tag';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateTagRequestSchema,
ZCreateTagResponseSchema,
ZDeleteTagRequestSchema,
ZFindTagsRequestSchema,
ZFindTagsResponseSchema,
ZGetEnvelopeTagsRequestSchema,
ZGetEnvelopeTagsResponseSchema,
ZSetEnvelopeTagsRequestSchema,
ZSetEnvelopeTagsResponseSchema,
ZUpdateTagRequestSchema,
ZUpdateTagResponseSchema,
} from './schema';
export const tagRouter = router({
/**
* @public
*/
findTags: authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/tag',
summary: 'Find tags',
description: 'Find tags for the current team based on a search criteria',
tags: ['Tag'],
},
})
.input(ZFindTagsRequestSchema)
.output(ZFindTagsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { type, query, page, perPage } = input;
ctx.logger.info({
input: {
type,
query,
},
});
return await findTags({
userId: user.id,
teamId,
type,
query,
page,
perPage,
});
}),
/**
* @private
*/
getEnvelopeTags: authenticatedProcedure
.input(ZGetEnvelopeTagsRequestSchema)
.output(ZGetEnvelopeTagsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await getEnvelopeTags({
userId: user.id,
teamId,
envelopeId,
});
}),
/**
* @public
*/
createTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/create',
summary: 'Create tag',
description: 'Creates a new tag in your team',
tags: ['Tag'],
},
})
.input(ZCreateTagRequestSchema)
.output(ZCreateTagResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { name, type } = input;
ctx.logger.info({
input: {
type,
},
});
return await createTag({
userId: user.id,
teamId,
name,
type,
});
}),
/**
* @public
*/
updateTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/update',
summary: 'Update tag',
description: 'Updates an existing tag',
tags: ['Tag'],
},
})
.input(ZUpdateTagRequestSchema)
.output(ZUpdateTagResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { tagId, data } = input;
ctx.logger.info({
input: {
tagId,
},
});
return await updateTag({
userId: user.id,
teamId,
tagId,
data,
});
}),
/**
* @public
*/
deleteTag: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/delete',
summary: 'Delete tag',
description: 'Deletes an existing tag and removes it from all assigned envelopes',
tags: ['Tag'],
},
})
.input(ZDeleteTagRequestSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { tagId } = input;
ctx.logger.info({
input: {
tagId,
},
});
await deleteTag({
userId: user.id,
teamId,
tagId,
});
return ZGenericSuccessResponse;
}),
/**
* @public
*/
setEnvelopeTags: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/tag/assign',
summary: 'Set envelope tags',
description: 'Set the full set of tags assigned to an envelope',
tags: ['Tag'],
},
})
.input(ZSetEnvelopeTagsRequestSchema)
.output(ZSetEnvelopeTagsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, tagIds } = input;
ctx.logger.info({
input: {
envelopeId,
tagCount: tagIds.length,
},
});
return await setEnvelopeTags({
userId: user.id,
teamId,
envelopeId,
tagIds,
});
}),
});
+61
View File
@@ -0,0 +1,61 @@
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZTagTypeSchema } from '@documenso/lib/types/tag-type';
import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema';
import { z } from 'zod';
export const ZTagSchema = TagSchema.pick({
id: true,
name: true,
type: true,
teamId: true,
createdAt: true,
updatedAt: true,
});
export type TTag = z.infer<typeof ZTagSchema>;
export const ZCreateTagRequestSchema = z.object({
name: z.string().min(1).max(50),
type: ZTagTypeSchema,
});
export const ZCreateTagResponseSchema = ZTagSchema;
export const ZUpdateTagRequestSchema = z.object({
tagId: z.string().describe('The ID of the tag to update'),
data: z.object({
name: z.string().min(1).max(50).optional().describe('The name of the tag'),
}),
});
export const ZUpdateTagResponseSchema = ZTagSchema;
export const ZDeleteTagRequestSchema = z.object({
tagId: z.string(),
});
export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({
type: ZTagTypeSchema.optional().describe('Filter tags by type'),
query: z.string().optional().describe('Search tags by name'),
});
export const ZFindTagsResponseSchema = ZFindResultResponse.extend({
data: z.array(ZTagSchema),
});
export const ZSetEnvelopeTagsRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to assign tags to'),
tagIds: z.array(z.string()).describe('The tag IDs to assign to the envelope'),
});
export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
export const ZGetEnvelopeTagsRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to get tags for'),
});
export const ZGetEnvelopeTagsResponseSchema = z.array(ZTagSchema);
export type TFindTagsResponse = z.infer<typeof ZFindTagsResponseSchema>;
export type TSetEnvelopeTagsResponse = z.infer<typeof ZSetEnvelopeTagsResponseSchema>;
export type TGetEnvelopeTagsResponse = z.infer<typeof ZGetEnvelopeTagsResponseSchema>;
@@ -2,6 +2,7 @@ import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelo
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
@@ -69,6 +70,11 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
enabled: true,
},
},
tags: {
include: {
tag: true,
},
},
},
});
@@ -115,6 +121,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
}
: null,
templateDocumentDataId: firstTemplateDocumentData.id, // Backwards compatibility.
tags: mapEnvelopeTagsToTags(envelope.tags),
};
});
@@ -26,6 +26,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags';
import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
import type { Envelope } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
@@ -120,6 +121,7 @@ export const templateRouter = router({
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
tags: mapEnvelopeTagsToTags(envelope.tags),
};
}),
};
@@ -167,6 +169,7 @@ export const templateRouter = router({
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
tags: mapEnvelopeTagsToTags(envelope.tags),
};
}),
};
@@ -280,6 +280,8 @@ export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
includeAllFolders: z.boolean().describe('Include templates from all folders.').optional(),
tagIds: z.array(z.string()).describe('Filter templates by tag IDs.').optional(),
});
export const ZFindOrganisationTemplatesRequestSchema = ZFindSearchParamsSchema;
+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>
);
};