mirror of
https://github.com/documenso/documenso.git
synced 2026-07-02 01:01:00 +10:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d20d9595c9 |
@@ -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 1–5 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.
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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([
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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