Compare commits

..
Author SHA1 Message Date
ephraimduncan 478229aa90 docs(api): address rate limit review findings
- document the real 429 bodies: v1 returns { message }, v2 returns the structured error object
- exclude CORS preflight responses from the header guarantee
- describe monthly quotas as organisation-wide api/document/email counters, not envelope-only
- retry example: honor Retry-After exactly; cap only the exponential fallback delay
2026-07-30 22:04:00 +00:00
ephraimduncan d3eb0c7999 docs(api): document rate limit headers and 429 variants
- document X-RateLimit-Limit/-Remaining/-Reset on every v1/v2/v2-beta response
- document Retry-After on 429s and epoch-aligned 1-minute windows (real wait is 1-60s)
- show both 429 body shapes: global error key vs AppError code/message/statusCode
- cover the three 429 sources including the headerless monthly quota; add v2-beta to scope
2026-07-30 22:04:00 +00:00
ephraimduncan 918e42b992 docs(webhooks): address review findings
- qualify the SSRF guard as best-effort (no DNS-rebinding coverage, fails open on lookup errors)
- use envelope IDs from the actual generator alphabet (no digits possible)
- fix template-events intro: templateId is null except on TEMPLATE_USED
2026-07-30 21:57:43 +00:00
ephraimduncan f21eddef19 docs(webhooks): correct retry policy, timeout and payload reference
- replace fabricated retry schedule with provider behavior (local 4, BullMQ 3, Inngest 5 attempts)
- fix webhook timeout from 30s to 10s and define failure semantics (3xx not followed, code 0)
- clarify failed deliveries never auto-disable a webhook; document SSRF rules and http:// support
- add envelopeId to all payload examples; frame numeric id as legacy v1 identifier
- remove phantom documentMeta field; fix hardcoded timezone/dateFormat values
- add missing status/source enum values and document the RECIPIENT_EXPIRED event
2026-07-30 21:37:24 +00:00
56 changed files with 1187 additions and 7332 deletions
File diff suppressed because it is too large Load Diff
@@ -1,175 +0,0 @@
---
date: 2026-08-04
title: Recipient Signing Groups
---
## Summary
Allow recipients to be **grouped into a single signing step** when "Enable signing order" (SEQUENTIAL) is on. Grouped recipients share the same `signingOrder` number and may act **in any order among themselves**; the next step only unlocks once **every** member of the group has completed their required action.
- A **step** = all non-CC recipients sharing one `signingOrder` value.
- A **group** = a step with 2+ members.
- Feature surface: **V2 envelope editor only** (`apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx`). Backend enforcement is global (any document with duplicate orders behaves correctly, including API-created ones).
No database schema changes: `Recipient.signingOrder` is already a nullable, non-unique `Int` (`packages/prisma/schema.prisma:647`), and all tRPC/REST schemas already accept duplicate values (`z.number().optional()` everywhere). Today duplicates are only destroyed by client-side normalization.
## Amendments (2026-08-04, post-implementation)
- The step badge copy is **"Group N"** (not "Step N").
- The signing-order number input was **removed entirely** — grouping, joining,
extraction and reordering are **drag-and-drop only**. The "type-to-join" and
"out-of-bounds number extraction" decisions below are superseded; the
`Ungroup` link remains as the non-drag affordance for dissolving a group.
- Known limitation: gap drop-zones keep a constant hit area (drop-target
geometry is captured at drag start, so drag-dependent resizing would
desynchronise the visible strip from the actual hit area).
## Product decisions (agreed)
| Topic | Decision |
| --- | --- |
| Group representation | Derived from duplicate `signingOrder` values. No new tables/columns. |
| Whole-group drag | Required. Step cards are draggable as a unit (nested Kanban DnD). |
| Grouping gestures | Drag a recipient/step onto a card (combine) **or** type an existing step number into the order input (type-to-join, DocuSign style). |
| Removing one member | Drag the member row out to a gap zone, **or** type an out-of-bounds number (> step count) to become a standalone step at the end. |
| Ungroup link | Dissolves the whole group into consecutive standalone steps, preserving relative order. |
| Dictate next signer | Coexists with groups. Dictation UI/rewrite applies **only** when the completing signer is the last unsigned member of their step **and** the next step has exactly one member. Otherwise dictation is silently skipped for that transition. |
| Assistants | May be grouped. A grouped assistant can only assist recipients in **strictly higher** steps (never group peers). |
| CSC/TSP (AES/QES) instances | Groups are blocked (editor validation). TSP signing path unchanged. |
| V1 editors | Unchanged. Editing recipients of a grouped document in a V1 surface flattens groups (accepted limitation). |
## Current-state reference
Key decision points that assume a single "next recipient":
- `packages/lib/server-only/document/send-document.ts:150-157` — SEQUENTIAL initial send notifies `.slice(0, 1)` of pending recipients.
- `packages/lib/server-only/document/complete-document-with-token.ts:368-459` — after completion, `const [nextRecipient] = pendingRecipients` is activated (sendStatus SENT) and emailed; dictation rewrites that single recipient.
- `packages/lib/server-only/recipient/get-is-recipient-turn.ts:40-49`**index-based** loop: everyone earlier in the sorted array must be SIGNED. Two recipients sharing an order would block each other.
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts:263-279` — duplicated inline copy of the same index-based loop (feeds V2 signing `isRecipientsTurn`).
- `packages/lib/server-only/recipient/get-next-pending-recipient.ts` — returns `recipients[currentIndex + 1]` for the dictate-next-signer form (V1 sign loader).
- `packages/lib/server-only/template/create-document-from-direct-template.ts:674-742` — same single-next assumption for direct-template dictation.
- Assistant scope: `packages/lib/server-only/recipient/get-recipients-for-assistant.ts` and the assistant branch of `packages/trpc/server/envelope-router/sign-envelope-field.ts` use `signingOrder: { gte: ... }`.
- Client normalization: `normalizeRecipientSigningOrders` (`packages/lib/utils/recipients.ts:50-68`) force-renumbers non-CC recipients `index + 1`, destroying duplicates. Used by V2 editor (via `packages/lib/client-only/hooks/use-editor-recipients.ts`) and V1 editors.
- Editor autosave: watch-effect in `envelope-editor-recipient-form.tsx:524-588` diffs signers (incl. `signingOrder`) and calls `setRecipientsDebounced``trpc.envelope.recipient.set` (1000 ms debounce); meta changes go through `envelope.update`.
- The canonical sort everywhere: `orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }]`.
## 1. Data model & ordering semantics
- Orders stay dense `1..K` (K = number of steps): a grouped document looks like `[1, 2, 3, 3, 4]`.
- CC recipients keep `signingOrder: undefined` and always sort after steps (unchanged).
- REJECTED semantics unchanged: turn checks treat `signingStatus !== SIGNED` (including REJECTED) as blocking; rejection independently cancels the document via the existing flow.
- Null orders (legacy/API data) sort last (treated as `+Infinity` in comparisons).
## 2. Shared pure utilities — `packages/lib/utils/recipient-groups.ts` (new)
Client-safe pure functions, fully unit-tested:
- `groupRecipientsBySigningOrder(signers)``{ steps: Array<{ order: number; members: T[] }>, ccRecipients: T[] }`. Steps sorted ascending; members keep array order.
- `normalizeGroupedSigningOrders(signers, canUpdate?)` → dense-renumbers steps **preserving duplicates**. Contract mirrors the flat normalizer: steps sort by current order and are renumbered by sequence position; a step containing a locked recipient (per `canUpdate`) keeps the locked member's persisted order; editable steps never collide into a locked step's number (no accidental grouping — they take the next free position). In practice locked recipients occupy a prefix of the sequence (sequential signing means earlier steps signed first), so positions and persisted orders agree; API-created oddities degrade gracefully like today. CC recipients get `undefined` and move to the tail.
- Editor operations (each returns a new, normalized signers array; no mutation):
- `reorderStep(signers, fromStepIndex, toStepIndex)`
- `mergeSteps(signers, sourceStepIndex, targetStepIndex)` — all source members adopt the target step's order
- `moveRecipientToStep(signers, formId, targetStepIndex)` — join a group
- `extractRecipientToNewStep(signers, formId, insertStepIndex)` — become a standalone step at that gap position
- `ungroupStep(signers, stepIndex)` — members become consecutive standalone steps
- `getDictatableNextRecipient(recipients, currentRecipientId)` → the single next-step recipient, or `null` when the current signer isn't the last unsigned member of their step or the next step has ≠ 1 member. Shared by server dictation logic and client dictate-form mirrors.
`normalizeRecipientSigningOrders` (flat) is left untouched for V1 surfaces. `isAssistantLastSigner` (`packages/lib/utils/recipients.ts:25-30`) becomes group-aware: warns when any ASSISTANT sits in the **last step** (equivalent behavior for ungrouped documents).
## 3. Editor UI — structure & visuals
Component split under `apps/remix/app/components/general/envelope-editor/`:
- `envelope-editor-recipient-form.tsx` — retains header actions (AI detect, Add Myself, Add Signer), signing-order/dictate checkboxes, autosave watch-effect (unchanged logic), dialogs, limits alert.
- `recipient-step-list.tsx` (new) — `DragDropContext`, outer step `Droppable`, gap drop-zones, step derivation via `groupRecipientsBySigningOrder(watchedSigners)`.
- `recipient-step-card.tsx` (new) — card chrome: card-level grip, `Step {n}` badge (`Badge variant="neutral"`), group header row (`Users2Icon` + `{n} signers · any order` via `plural()` + right-aligned `Ungroup` link `Button variant="link"`), inner member `Droppable`.
- `recipient-row.tsx` (new) — moved row internals: row grip, order input, email/name `RecipientAutoCompleteInput`, `RecipientRoleSelect`, delete button, advanced `RecipientActionAuthSelect`.
Rendering rules:
- Form state remains the single flat `signers` field array (react-hook-form indices = flat array positions); steps are derived at render time only.
- Sequential mode: every step renders as a bordered card. Group cards (2+ members) get the green accent treatment: `border-primary`-tinted border, light green background, green-tinted order inputs, group header row visible.
- Single-member steps: card with `Step {n}` badge, card grip, and the member row (with its own row grip) — no group header.
- CC recipients: plain non-draggable cards without badge/order input, rendered after the last step.
- Parallel mode (signing order off): render today's flat rows — no cards, badges, or grouping UI.
- All new strings use `<Trans>`/`t`/`plural` macros.
## 4. Editor UI — interactions
### Drag & drop (nested Kanban, `@hello-pangea/dnd`)
- Outer `Droppable` `type="STEP"` (vertical) contains one `Draggable` per step; drag handle = card grip. `isCombineEnabled` on.
- Each step card contains an inner `Droppable` `type="RECIPIENT"` with one `Draggable` per member; drag handle = row grip.
- Gap zones: slim `Droppable`s of `type="RECIPIENT"` rendered between cards and at both ends. Collapsed (`h-2`, invisible) normally; while a RECIPIENT drag is active (tracked via `onBeforeCapture`), they expand to dashed strips (per mock image 2).
| Gesture | DnD result | Operation |
| --- | --- | --- |
| Card grip → drop between cards | `type=STEP`, `destination` | `reorderStep` |
| Card grip → drop onto another card's center | `type=STEP`, `combine` | `mergeSteps` |
| Row grip → drop onto another step card | `type=RECIPIENT`, destination = that card's inner droppable | `moveRecipientToStep` |
| Row grip → drop on a gap zone | `type=RECIPIENT`, destination = gap droppable | `extractRecipientToNewStep` |
| Row grip → drop within own step | destination = own inner droppable | no-op |
Hover affordances: target card shows a green ring + floating `Release to sign together` badge (with users icon) when it is a combine target (`snapshot.combineTargetFor`) **or** an inner-droppable hover target (`snapshot.isDraggingOver`). Existing drag styling (widget background, pointer-events) carries over.
After every operation: normalize → `form.setValue('signers', ...)` (validate + dirty) → assistant-last-step warning toast when applicable → `form.trigger('signers')`. The existing watch-effect autosaves.
### Signing-order number input
- `min=1`, `max=stepCount + 1` (spinner + typed, `data-testid="signing-order-input"` kept).
- Value `N` where `N` = own step → no-op.
- `N` in `1..K`, other step → `moveRecipientToStep` (type-to-join; also merges two solo steps into a group).
- `N > K``extractRecipientToNewStep` at the end (out-of-bounds extraction).
- Invalid input (empty, non-integer, `< 1`) → ignored (current behavior).
### Other interactions
- **Ungroup** link → `ungroupStep`.
- **Add Signer / Add Myself / AI detection** → new standalone step at the end (`signingOrder = stepCount + 1`).
- **Remove signer** → existing flow + group-aware normalize (a group of 2 losing a member dissolves into a plain step; empty steps disappear).
- **Role change to CC** → member leaves its step (normalize moves it to the tail). Role change to ASSISTANT inside a group is allowed.
- **Locked recipients** (signed or inserted fields, per `canRecipientBeModified`): row controls disabled as today. A step containing a locked member cannot have its order changed — card grip disabled (no drag/reorder, no combining it *into* another step) and Ungroup disabled. It **may** still receive new members (inner drop, combine-as-target, type-to-join), since that never alters the locked member's order; editable peers may still be dragged out individually.
- **Drag disabled** entirely when: parallel mode, submitting, or (per draggable) CC/locked — matching current `isDragDisabled` rules.
## 5. Backend signing flow (group-aware)
Single shared predicate (pure, in `recipient-groups.ts`): *a recipient may act iff no non-CC recipient with `signingStatus !== SIGNED` has a strictly lower `signingOrder` (null = ∞)*.
1. **Turn check**`get-is-recipient-turn.ts` replaces its index loop with the predicate; `get-envelope-for-recipient-signing.ts:263-279` deletes its inline copy and calls the same helper. Both keep their existing queries (add the `nulls: 'last'` + `id` tiebreaker to the sort in both for consistency).
2. **Initial send**`send-document.ts`: SEQUENTIAL now notifies **all** pending non-CC recipients holding the minimum pending order (replaces `.slice(0, 1)`).
3. **Completion advance**`complete-document-with-token.ts`: compute `nextGroup` = pending (non-SIGNED, non-CC) recipients at the minimum order. Activate (sendStatus SENT + sentAt) and email **only members with `sendStatus !== SENT`**, each via the existing `send.signing.requested.email` job. This one rule covers both cases: mid-group completion (remaining peers already SENT → nothing sent, no advance) and step transition (all next-step members activated together). The "waiting for others" pending email to the just-signed recipient is unchanged. Mirror the same logic in `create-document-from-direct-template.ts`.
4. **Dictate next signer** — rewrite (`nextSigner` name/email + RECIPIENT_UPDATED audit log) applies only when `allowDictateNextSigner && nextGroup.length === 1` and that member is freshly activated (`sendStatus !== SENT`). Server form source `get-next-pending-recipient.ts` and the client mirrors (`envelope-signing-provider.tsx`, `document-signing-page-view-v1.tsx`, `direct-template-signing-form.tsx`) all switch to `getDictatableNextRecipient` — the form only renders when the completing signer is the last unsigned member of their step and the next step has exactly one member.
5. **Assistants**`get-recipients-for-assistant.ts` and the assistant branch of `sign-envelope-field.ts` change `gte` → strictly-greater semantics so grouped assistants cannot act for group peers. Implementation must verify whether the current `gte` lists include the assistant themself and preserve that self-inclusion explicitly (`OR id = assistant.id`) if so.
6. **CSC/TSP** — no changes to `execute-tsp-sign.ts` (head-of-queue advance stays safe even if duplicates arrive via API). The editor blocks group creation on CSC instances via the existing CSC `superRefine` in `ZEditorRecipientsFormSchema`: add an issue when any non-CC duplicate `signingOrder` exists.
No email template, webhook, audit-log, or job-definition changes: activation emails, events, and logs are already per-recipient.
## 6. Validation & compatibility
- `ZEditorRecipientsFormSchema` gains the CSC no-duplicates issue only; tRPC/REST schemas stay `z.number().optional()` (duplicates are now legitimate).
- Templates: groups carry into created documents (`signingOrder` copies verbatim in template → document creation and document duplication). Verified by unit/E2E coverage.
- V1 editors and embed authoring keep the flat normalizer: opening & saving recipients there flattens groups into consecutive steps (accepted, documented limitation).
- API consumers that already send duplicate orders gain correct parallel-group behavior automatically.
## 7. Testing
**Unit (vitest, `packages/lib`)** — new `recipient-groups.test.ts` (+ extend `recipients.test.ts`):
- Step derivation (duplicates, nulls, CC exclusion, stable member order).
- `normalizeGroupedSigningOrders`: preserves groups, compacts gaps, locked-recipient anchoring, CC tail.
- Each editor operation: merge, join, extract (incl. out-of-bounds), reorder, ungroup, dissolve-on-removal.
- Turn predicate: group member allowed when lower steps signed; blocked by any lower unsigned/REJECTED; parallel mode; null orders; single-member equivalence with old behavior.
- `getDictatableNextRecipient`: eligibility matrix (mid-group vs last-of-group × next step size 1/2+/none).
**E2E (Playwright, `packages/app-tests`, per the envelope-editor-v2-e2e skill)**:
- Editor: type-to-join creates a group (badge `Step 3`, header `2 signers · any order`, green styling), persistence after reload, Ungroup restores sequential steps, out-of-bounds extraction.
- Signing flow: document `[1, (2,2), 3]` — after step 1 signs, both group members can access signing (either order); step 3 is blocked (waiting page) until both complete, then unlocks; activation emails fire once per member.
- One best-effort drag smoke test (combine two solo steps); drag logic correctness is otherwise covered by unit tests.
## 8. Out of scope
- Named groups, quorum ("k of n") semantics, or per-group metadata (would require an explicit group entity — future migration if ever needed).
- Whole-group drag *into* another group via card grip is a merge (`mergeSteps`); there is no "insert group inside group" concept.
- V1 editor group awareness.
- TSP/CSC parallel signing.
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
The rate limit applies to:
- `/api/v1/*`
- `/api/v2/*`
- `/api/v2-beta/*`
**Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Rate Limit Response
### Global per-IP 429 Response
```json
{
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
}
```
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
### Rate Limit Headers
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
and carry no rate limit headers:
| Header | Description |
| ----------------------- | ---------------------------------------------------------------------- |
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
## Resource Limits
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Error Response
### Organisation Limit 429 Responses
When you exceed a resource limit:
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
shape depends on the API version, and neither matches the global per-IP limiter's
`{ "error": "..." }` body.
On `/api/v1/*`, the body contains only a message:
```json
{
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
```
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
"code": "TOO_MANY_REQUESTS",
"data": {
"code": "TOO_MANY_REQUESTS",
"httpStatus": 429,
"appError": {
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
}
}
```
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
their own window. Monthly quota responses carry no quota-specific rate limit headers or
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
## Error Codes
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
| Code | Status | Description |
| ------------------- | ------ | ------------------------------------------------------------------ |
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
There are three sources of `TOO_MANY_REQUESTS` responses:
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
3. Organisation monthly quotas for the same three counters. Every authenticated API request
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
exhausted — not just envelope-related ones.
---
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
2. When an event occurs, Documenso sends an HTTP POST to your URL
3. Your application processes the event and responds with 200 OK
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
---
@@ -304,7 +304,6 @@ Each webhook call shows the following details:
- Timestamp
- Response code
- Request and response bodies
- Response headers
Click any call to see full details including headers and response data.
</Step>
@@ -1,377 +0,0 @@
import {
createZipWriter,
sanitizeZipPathSegment,
type ZipFileEntry,
} from '@documenso/lib/client-only/create-zip-writer';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
/**
* The maximum number of documents that can be downloaded in a single bulk
* download. Each document requires fetching its full PDFs into the browser,
* so this bounds both request volume and blob storage usage. Matches the
* spirit of the server-side 100 cap on bulk move/delete/cancel.
*/
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
export type EnvelopeBulkDownloadItem = {
id: string;
title: string;
status: DocumentStatus;
/**
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
* different field-rendering pipeline that the partial PDF helper does not
* implement, so the Partial option is hidden for them.
*/
isLegacy: boolean;
};
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
export type EnvelopesBulkDownloadDialogProps = {
envelopes: EnvelopeBulkDownloadItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (successfulEnvelopeIds: string[]) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const abortRef = useRef(false);
const trpcUtils = trpc.useUtils();
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
useEffect(() => {
if (!open) {
return;
}
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
setProgress(0);
}, [open]);
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
versionMap[envelope.id] ?? getDefaultVersion(envelope);
/**
* The version options selectable for an envelope, mirroring the gating used
* by the single envelope download dialog:
* - COMPLETED: signed or original.
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
* field-rendering pipeline the partial PDF helper does not implement.
* - Anything else: original only, so no choice is shown.
*/
const getVersionOptions = (
envelope: EnvelopeBulkDownloadItem,
): { value: BulkDownloadVersion; label: string }[] | null => {
if (envelope.status === DocumentStatus.COMPLETED) {
return [
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
return [
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
];
}
return null;
};
const getStatusLabel = (status: DocumentStatus) =>
match(status)
.with(DocumentStatus.COMPLETED, () => t`Completed`)
.with(DocumentStatus.PENDING, () => t`Pending`)
.with(DocumentStatus.DRAFT, () => t`Draft`)
.with(DocumentStatus.REJECTED, () => t`Rejected`)
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
.exhaustive();
const onDownload = async () => {
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
return;
}
abortRef.current = false;
setIsDownloading(true);
setProgress(0);
const zipWriter = createZipWriter();
const successfulEnvelopeIds: string[] = [];
let failedDownloads = 0;
try {
for (const envelope of envelopes) {
if (abortRef.current) {
break;
}
try {
const downloadVersion = getDownloadVersion(envelope);
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
envelopeId: envelope.id,
access: {
type: 'user',
},
});
// Each envelope's items are grouped in their own folder. The id
// prefix guarantees uniqueness, the truncated title keeps it
// readable without risking overly long extraction paths.
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
// Buffer this envelope's files before writing so a failed envelope
// is either fully in the zip or not at all. Files from previous
// envelopes have already been written to the zip stream and freed.
const envelopeFiles: ZipFileEntry[] = [];
for (const envelopeItem of envelopeItems) {
const { filename, blob } = await fetchPDF({
envelopeItem,
token: undefined,
fileName: envelopeItem.title,
version: downloadVersion,
});
envelopeFiles.push({
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
data: blob,
});
}
for (const file of envelopeFiles) {
await zipWriter.addFile(file);
}
successfulEnvelopeIds.push(envelope.id);
} catch (error) {
console.error(error);
failedDownloads++;
}
setProgress((p) => p + 1);
}
// The user intentionally stopped the download, discard anything fetched
// so far without toasting an error.
if (abortRef.current) {
zipWriter.abort();
return;
}
if (successfulEnvelopeIds.length === 0) {
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
try {
downloadFile({
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
data: zipWriter.finalize(),
});
} catch (error) {
console.error(error);
zipWriter.abort();
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
if (failedDownloads > 0) {
toast({
title: t`Documents partially downloaded`,
description: t`${plural(successfulEnvelopeIds.length, {
one: '# document downloaded.',
other: '# documents downloaded.',
})} ${plural(failedDownloads, {
one: '# document could not be downloaded.',
other: '# documents could not be downloaded.',
})}`,
variant: 'destructive',
});
onSuccess?.(successfulEnvelopeIds);
return;
}
toast({
title: t`Documents downloaded`,
description: plural(successfulEnvelopeIds.length, {
one: '# document has been downloaded.',
other: '# documents have been downloaded.',
}),
});
onSuccess?.(successfulEnvelopeIds);
onOpenChange(false);
} finally {
setIsDownloading(false);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
</AlertDescription>
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
const versionOptions = getVersionOptions(envelope);
return (
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
{envelope.title}
</p>
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
</div>
{versionOptions && (
<RadioGroupSegmented
className="shrink-0"
value={getDownloadVersion(envelope)}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as BulkDownloadVersion,
}))
}
aria-label={t`Download version for ${envelope.title}`}
>
{versionOptions.map((option) => (
<RadioGroupSegmentedItem key={option.value} value={option.value}>
{option.label}
</RadioGroupSegmentedItem>
))}
</RadioGroupSegmented>
)}
</div>
);
})}
</div>
</div>
{isDownloading && (
<p className="text-muted-foreground text-sm">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDownloading) {
abortRef.current = true;
} else {
onOpenChange(false);
}
}}
>
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0 || isOverDownloadLimit}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -11,7 +11,6 @@ import {
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@@ -224,12 +223,27 @@ export const DirectTemplateSigningForm = ({
return undefined;
}
return (
getDictatableNextRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
}) ?? undefined
);
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
@@ -15,7 +15,6 @@ import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
@@ -144,11 +143,31 @@ export const DocumentSigningPageViewV1 = ({
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? undefined;
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
@@ -6,7 +6,6 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -291,14 +290,32 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted);
const nextRecipient = useMemo(() => {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
return getDictatableNextRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
}
if (a.signingOrder === null) {
return 1;
}
if (b.signingOrder === null) {
return -1;
}
if (a.signingOrder === b.signingOrder) {
return a.id - b.id;
}
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
@@ -2,24 +2,38 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const { _ } = useLingui();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [searchParams, setSearchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState(query ?? '');
const [searchTerm, setSearchTerm] = useState(initialValue);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
setSearchParams(params);
},
[searchParams],
);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, query, setQuery]);
}, [debouncedSearchTerm, searchParams]);
return (
<Input
@@ -1,30 +1,42 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
updateEditorSigners,
ZEditorRecipientsFormSchema,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import {
isAssistantLastSigner,
isCcRecipient,
normalizeRecipientSigningOrders,
canRecipientBeModified as utilCanRecipientBeModified,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd';
import { plural } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
@@ -34,8 +46,6 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
useCurrentEnvelopeEditor();
@@ -43,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
@@ -50,6 +61,7 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
@@ -95,8 +107,23 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients } = envelope;
const { recipients, fields } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { form } = editorRecipients;
@@ -134,20 +161,17 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
// Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
// leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
const { fields: signers, remove: removeSigner } = useFieldArray({
control,
name: 'signers',
keyName: 'nativeId',
});
const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
@@ -159,22 +183,39 @@ export const EnvelopeEditorRecipientForm = () => {
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
// Note: Watched signer entries can be transiently partial while react-hook-form
// re-registers reordered array fields, so guard optional access here.
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
const canRecipientBeModified = (recipientId?: number) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = recipients.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return false;
}
return utilCanRecipientBeModified(recipient, fields);
};
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
updateEditorSigners(form, updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
@@ -192,7 +233,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: stepCount + 1,
signingOrder: activeRecipientCount + 1,
});
};
@@ -204,8 +245,8 @@ export const EnvelopeEditorRecipientForm = () => {
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
updateEditorSigners(
form,
form.setValue(
'signers',
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
@@ -214,6 +255,10 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
@@ -240,7 +285,10 @@ export const EnvelopeEditorRecipientForm = () => {
nextSigningOrder += 1;
}
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: plural(detectedRecipients.length, {
@@ -254,6 +302,32 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
if (!canRecipientBeModified(signer.id)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
}
};
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -274,7 +348,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: stepCount + 1,
signingOrder: activeRecipientCount + 1,
},
true,
);
@@ -283,6 +357,142 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders(items);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
await form.trigger('signers');
},
[form, canRecipientBeModified, watchedSigners, toast],
);
const handleRoleChange = useCallback(
(index: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
// Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeSigningOrders(
currentSigners.map((signer, idx) => ({
...signer,
role: idx === index ? role : signer.role,
})),
);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[index];
if (isCcRecipient(signer)) {
return;
}
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
if (currentSigningOrderIndex === -1) {
return;
}
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
nonCcSigners.splice(newPosition, 0, reorderedSigner);
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
},
[form, canRecipientBeModified, toast],
);
const handleSigningOrderDisable = useCallback(() => {
setShowSigningOrderConfirmation(false);
@@ -294,8 +504,10 @@ export const EnvelopeEditorRecipientForm = () => {
})),
);
updateEditorSigners(form, updatedSigners);
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
@@ -376,7 +588,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
return (
<Card backdropBlur={false} className="border">
@@ -432,7 +644,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button"
className="flex-1"
size="sm"
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -582,7 +794,287 @@ export const EnvelopeEditorRecipientForm = () => {
)}
</div>
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isCcRecipient(signer) ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={activeRecipientCount}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"
@@ -1,233 +0,0 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
const RecipientRowInner = ({
signerIndex,
signer,
isSequential,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* Memoized: rows contain heavy inputs (autocomplete, role select) and would
* otherwise re-render on every drag state change, making drags feel sluggish.
* All callback props are stable (useCallback in the list) and `signer` object
* identities only change when form values actually change.
*/
export const RecipientRow = memo(RecipientRowInner);
@@ -1,247 +0,0 @@
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
/**
* Skips the drop animation. The post-drop state update re-sorts and renumbers
* the groups anyway, so gliding to the predicted slot first makes every drop
* feel like it settles twice — snapping hands control to the real re-render
* immediately instead.
*/
const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
if (!snapshot.isDropAnimating) {
return provided.draggableProps.style;
}
return {
...provided.draggableProps.style,
transitionDuration: '0.001s',
};
};
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
isLastStep: boolean;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
/**
* The drop-zone strip rendered above each group card (and below the last one)
* that receives recipient-row drops. Invisible until a dragged row hovers it,
* then it shows a full-width green line marking the insertion point.
*
* Notes:
* - It lives INSIDE the step's Draggable so it shifts together with the card
* while groups are being reordered — a static strip between draggables
* would stay behind while the cards around it are displaced, making group
* drags look broken.
* - Its `droppableId` must stay STABLE while mounted (anchored to a formId,
* never a positional index): @hello-pangea/dnd does not support changing
* ids on mounted droppables/draggables, which silently breaks them.
* - `type="RECIPIENT"` already scopes it to recipient-row drags, and
* `isDropDisabled` must not be toggled based on the active drag, as
* @hello-pangea/dnd snapshots it at drag start (before state updates land).
* - It must keep a CONSTANT size: droppable geometry is captured when a drag
* starts, so resizing during the drag would leave the visible strip and the
* actual hit area in different places. Only colors may change mid-drag.
*/
const RecipientStepGap = ({ droppableId }: { droppableId: string }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
export const RecipientStepCard = ({
stepIndex,
step,
isLastStep,
draggableProvided,
draggableSnapshot,
draggingType,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
// All droppable ids are anchored to the first member's formId (never a
// positional index) so they stay stable while cards are reordered —
// @hello-pangea/dnd does not support changing ids on mounted elements.
const stepAnchor = step.members[0].formId;
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT">
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {step.order}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Trans>{step.members.length} recipients · any order</Trans>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || !canBeModified}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -1,359 +0,0 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import {
type TEditorRecipientsFormSchema,
updateEditorSigners,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
groupRecipientsBySigningOrder,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { Badge } from '@documenso/ui/primitives/badge';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { envelope, editorRecipients, isEmbedded } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
updateEditorSigners(form, updatedSigners);
if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
// Drag-and-drop ids are anchored to the first member's formId so they
// stay stable across reorders; resolve them back to step indexes here.
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const findStepIndexByAnchor = (anchorFormId: string) =>
currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId);
if (result.type === 'STEP') {
if (result.combine) {
const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId === 'gap-end') {
applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified));
return;
}
if (droppableId.startsWith('gap-')) {
const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length));
if (insertStepIndex === -1) {
return;
}
applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length));
if (targetStepIndex === -1) {
return;
}
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners],
);
const sharedRowProps = {
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = step.members.some((member) => !canSignerBeModified(member));
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -1,186 +0,0 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Separator } from '@documenso/ui/primitives/separator';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
export type FilterPillOption = {
value: string;
label: ReactNode;
trailing?: string;
};
type FilterPillCommonProps = {
icon: LucideIcon;
label: ReactNode;
options: FilterPillOption[];
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
testId?: string;
};
export type FilterPillSingleProps = FilterPillCommonProps & {
multiple?: false;
value: string | null;
onChange: (value: string | null) => void;
selectedLabel?: ReactNode;
};
export type FilterPillMultipleProps = FilterPillCommonProps & {
multiple: true;
value: string[];
onChange: (value: string[]) => void;
};
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
/**
* A faceted filter pill.
*
* Renders as a dashed "add a filter" pill at rest, and shows the current
* selection inline once a value is picked. Selecting the active option
* again (or the Clear row) removes it.
*
* Single select by default, closing on pick. When `multiple` is set the
* popover stays open for toggling, and the trigger shows the first two
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const [open, setOpen] = useState(false);
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
const selectedOptions = selectedValues
.map((value) => options.find((option) => option.value === value))
.filter((option): option is FilterPillOption => option !== undefined);
const hasSelection = selectedOptions.length > 0;
const extraCount = selectedOptions.length - 2;
const onSelect = (nextValue: string) => {
if (props.multiple) {
const newValues = selectedValues.includes(nextValue)
? selectedValues.filter((value) => value !== nextValue)
: [...selectedValues, nextValue];
props.onChange(newValues);
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
setOpen(false);
};
const onClear = () => {
if (props.multiple) {
props.onChange([]);
} else {
props.onChange(null);
}
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={loading}
className={cn('border-dashed text-muted-foreground', {
'border-solid text-foreground': hasSelection,
})}
data-testid={testId}
>
<Icon className="mr-2 h-4 w-4" />
{label}
{hasSelection && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
{props.multiple ? (
<span className="flex items-center gap-x-1">
{selectedOptions.slice(0, 2).map((option) => (
<Badge key={option.value} variant="neutral" size="small">
{option.label}
</Badge>
))}
{extraCount > 0 && (
<Badge variant="neutral" size="small">
<Trans>+{extraCount} more</Trans>
</Badge>
)}
</span>
) : (
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
)}
</>
)}
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
<CommandList>
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
<CheckIcon
className={cn(
'mr-2 h-4 w-4 shrink-0',
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
{option.trailing !== undefined && (
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
)}
</CommandItem>
))}
</CommandGroup>
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
<Trans>Clear</Trans>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -1,40 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
const PERIOD_OPTIONS = [
{ value: '7d', label: <Trans>Last 7 days</Trans> },
{ value: '14d', label: <Trans>Last 14 days</Trans> },
{ value: '30d', label: <Trans>Last 30 days</Trans> },
];
export const DocumentsTablePeriodFilter = () => {
const [{ period }, setSearchParams] = useQueryStates(
{
period: documentsSearchParams.period,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newPeriod: string | null) => {
void setSearchParams({
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
page: null,
});
};
return (
<FilterPill
icon={CalendarIcon}
label={<Trans>Period</Trans>}
value={period}
onChange={onChange}
options={PERIOD_OPTIONS}
testId="documents-table-period-filter"
/>
);
};
@@ -1,61 +1,63 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
type DocumentsTableSenderFilterProps = {
teamId: number;
};
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isMounted = useIsMounted();
const [{ senderIds }, setSearchParams] = useQueryStates(
{
senderIds: documentsSearchParams.senderIds,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const options = (data ?? []).map((member) => ({
const comboBoxOptions = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newSenderIds: string[]) => {
void setSearchParams({
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
page: null,
});
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('senderIds', newSenderIds.join(','));
if (newSenderIds.length === 0) {
params.delete('senderIds');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Sender</Trans>}
value={selectedSenderIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="font-normal text-muted-foreground">
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted || isLoading}
testId="documents-table-sender-filter"
options={comboBoxOptions}
selectedValues={senderIds}
onChange={onChange}
/>
);
};
@@ -1,98 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationType } from '@prisma/client';
import { ListFilterIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo } from 'react';
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
status: documentsSearchParams.status,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
);
const selectedStatus = useMemo(
() => selectableStatuses.find((value) => value === status) ?? null,
[selectableStatuses, status],
);
const onChange = (newStatus: string | null) => {
void setSearchParams({
status: selectableStatuses.find((value) => value === newStatus) ?? null,
page: null,
});
};
return (
<>
<FilterPill
icon={ListFilterIcon}
label={<Trans>Status</Trans>}
value={selectedStatus}
onChange={onChange}
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
</>
);
};
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
];
const formatStatsCount = (count: number) => {
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
};
@@ -1,11 +1,9 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
@@ -14,7 +12,6 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
@@ -22,106 +19,37 @@ export const EnvelopesTableBulkActionBar = ({
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
// when handling Escape, so this only clears the selection when nothing
// else consumed the key press.
if (event.key === 'Escape' && !event.defaultPrevented) {
onClearSelection();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedCount, onClearSelection]);
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="mx-1 h-5 w-px bg-border" />
<div className="h-6 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Trans>Cancel</Trans>
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
</Button>
</div>
);
@@ -1,57 +1,59 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
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 { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
import { XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import {
type EnvelopeBulkDownloadItem,
EnvelopesBulkDownloadDialog,
} from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Documents`);
}
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// Stable initial values: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable, and the metadata cache
// effect below depends on that setter.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
@@ -59,18 +61,9 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`documents-bulk-selection-meta-${team.id}`,
EMPTY_ENVELOPE_META_CACHE,
);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -88,18 +81,14 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.ALL]: 0,
});
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
history: 'push',
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
status: findDocumentSearchParams.status ?? undefined,
period: findDocumentSearchParams.period ?? undefined,
senderIds: findDocumentSearchParams.senderIds ?? undefined,
page: findDocumentSearchParams.page ?? undefined,
perPage: findDocumentSearchParams.perPage ?? undefined,
query: findDocumentSearchParams.query ?? undefined,
...findDocumentSearchParams,
folderId,
},
{
@@ -107,66 +96,34 @@ export default function DocumentsPage() {
},
);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: EnvelopeMetaCache = {};
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
params.set('status', value);
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
isLegacy: document.internalVersion === 1,
};
}
}
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id): EnvelopeBulkDownloadItem | null => {
const meta = envelopeMetaCache[id];
if (params.has('page')) {
params.delete('page');
}
if (!meta) {
return null;
}
let path = formatDocumentsPath(team.url);
return {
id,
title: meta.title,
status: meta.status,
// Stale cache entries predating this field are treated as legacy so
// the Partial option is never offered without certainty.
isLegacy: meta.isLegacy ?? true,
};
})
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
if (folderId) {
path += `/f/${folderId}`;
}
const hasActiveFilters = useMemo(() => {
return Boolean(
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
findDocumentSearchParams.senderIds?.length ||
findDocumentSearchParams.period,
);
}, [findDocumentSearchParams]);
if (params.toString()) {
path += `?${params.toString()}`;
}
const onResetFilters = () => {
void setFindDocumentSearchParams({
status: null,
senderIds: null,
period: null,
page: null,
});
return path;
};
useEffect(() => {
@@ -180,40 +137,69 @@ export default function DocumentsPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<DocumentsTableStatusFilter stats={stats} />
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.EXPIRED,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
{team && <DocumentsTableSenderFilter teamId={team.id} />}
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
<DocumentsTablePeriodFilter />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -252,28 +238,12 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -26,14 +26,12 @@ import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags(msg`Templates`);
}
// Stable initial value: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -49,11 +47,7 @@ export default function TemplatesPage() {
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`templates-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -1,19 +0,0 @@
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const;
/**
* Shared nuqs parsers for the documents page URL state.
*
* Used by the documents page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const documentsSearchParams = {
status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)),
period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES),
senderIds: parseAsArrayOf(parseAsInteger),
page: parseAsInteger,
perPage: parseAsInteger,
query: parseAsString,
};
+3 -10
View File
@@ -22,7 +22,6 @@
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -20081,9 +20080,9 @@
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/file-selector": {
@@ -26686,12 +26685,6 @@
"web-vitals": "^4.2.4"
}
},
"node_modules/posthog-js/node_modules/fflate": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
"license": "MIT"
},
"node_modules/posthog-node": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
-1
View File
@@ -94,7 +94,6 @@
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -1,118 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const seedOwnerWithDraft = async () => {
const owner = await seedUser();
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
createDocumentOptions: { title: 'File Download Auth Test' },
});
return { owner, draft, draftItem: draft.envelopeItems[0] };
};
test.describe('Envelope item file download endpoint authorization', () => {
test('rejects an unauthenticated download request', async ({ request }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a download request from a user outside the organisation', async ({ page }) => {
const { draft, draftItem } = await seedOwnerWithDraft();
const { user: outsider } = await seedUser();
await apiSignin({ page, email: outsider.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(403);
});
test('returns 404 for a nonexistent envelope', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const res = await page.request.get(
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('rejects a pending version download for a draft envelope', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
const owner = await seedUser();
const { user: recipient } = await seedUser();
// Default internalVersion is 1 (legacy).
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
createDocumentOptions: { title: 'Legacy Pending Download Test' },
});
const envelopeItem = pendingDocument.envelopeItems[0];
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows the owner to download their own document', async ({ page }) => {
const { owner, draft, draftItem } = await seedOwnerWithDraft();
await apiSignin({ page, email: owner.user.email });
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
expect(res.ok()).toBeTruthy();
expect(res.headers()['content-type']).toContain('application/pdf');
const body = await res.body();
// %PDF magic bytes.
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
});
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
const { draftItem } = await seedOwnerWithDraft();
const res = await request.get(
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
});
@@ -1,5 +1,3 @@
import fs from 'node:fs';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
@@ -7,7 +5,6 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { unzipSync } from 'fflate';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
@@ -53,10 +50,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
@@ -70,7 +67,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -83,11 +80,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
@@ -101,13 +98,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -116,122 +113,6 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
const teamBUrl = `team-b-${Date.now()}`;
await createTeam({
userId: sender.user.id,
teamName: 'Team B',
teamUrl: teamBUrl,
organisationId: sender.organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
await seedDraftDocument(sender.user, teamB.id, [], {
createDocumentOptions: { title: 'Team B Doc' },
});
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// The selection made in team A must not appear in team B.
await page.goto(`/t/${teamBUrl}/documents`);
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
// Returning to team A restores its selection.
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByText(/1\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape while a dialog is open should close the dialog but keep the selection.
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
// Escape with no dialog open should clear the selection.
await page.keyboard.press('Escape');
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
const { sender, documents } = await seedBulkActionsTestRequirements();
const [doc1, doc2] = documents;
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
await dialog.getByRole('button', { name: 'Download' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
const downloadPath = await download.path();
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
// Each envelope's files are nested inside an `envelopeId_title` folder.
expect(Object.keys(zipContents).sort()).toEqual(
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
);
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
for (const entry of Object.values(zipContents)) {
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
}
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
@@ -271,14 +152,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -291,13 +172,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -318,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -355,14 +236,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -61,10 +61,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
// The cancelled document is still listed.
await selectDocumentStatusFilter(page, 'Cancelled');
await page.getByRole('tab', { name: 'Cancelled' }).click();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -128,7 +131,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await selectDocumentStatusFilter(page, 'Cancelled');
await page.getByRole('tab', { name: 'Cancelled' }).click();
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentCounts } from '../fixtures/documents';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -174,7 +174,11 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
@@ -203,7 +207,11 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
@@ -231,7 +239,11 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
// Sign into the recipient account.
await apiSignout({ page });
@@ -243,7 +255,11 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
@@ -284,7 +300,11 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
// Sign into the sender account.
await apiSignout({ page });
@@ -295,7 +315,11 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Check document counts for sender.
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
// Sign into the other recipient account.
await apiSignout({ page });
@@ -306,5 +330,9 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Check document counts for other recipient.
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
@@ -20,7 +20,7 @@ import {
} from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({
mode: 'parallel',
@@ -61,7 +61,10 @@ test.describe('Find Documents UI - Personal Context', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 });
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
});
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
@@ -137,9 +140,10 @@ test.describe('Find Documents UI - Personal Context', () => {
redirectPath: `/t/${ownerTeam.url}/documents`,
});
// Inbox should be 0 since there's no team email and received docs are on sender's team.
// Owner's own doc should still show in All.
await checkDocumentCounts(page, { inbox: 0, all: 1 });
// Inbox should be 0 since there's no team email and received docs are on sender's team
await checkDocumentTabCount(page, 'Inbox', 0);
// Owner's own doc should still show in All
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible();
});
@@ -703,8 +707,9 @@ test.describe('Find Documents UI - Team with Team Email', () => {
redirectPath: `/t/${team.url}/documents`,
});
// Inbox should be 0, but pending should still show.
await checkDocumentCounts(page, { inbox: 0, pending: 1 });
await checkDocumentTabCount(page, 'Inbox', 0);
// But pending should still show
await checkDocumentTabCount(page, 'Pending', 1);
});
test('documents sent BY team email user should appear in team context', async ({ page }) => {
@@ -805,9 +810,12 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
});
// UserA should see only their own docs
await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 });
await checkDocumentTabCount(page, 'All', 3);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Completed', 1);
// Verify no B docs leaked
await page.getByRole('tab', { name: 'All' }).click();
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
@@ -958,9 +966,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
redirectPath: `/t/${outsideTeam.url}/documents`,
});
// Only the outside user's own draft should appear (cross-team docs are not visible).
// Inbox is 0 since there is no team email.
await checkDocumentCounts(page, { inbox: 0, all: 1 });
// Only the outside user's own draft should appear (cross-team docs are not visible)
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
@@ -1005,10 +1013,12 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${ownerTeam.url}/documents`,
});
// Only owner's own docs appear (received docs are on sender's team).
// Inbox is 0 since there is no team email, and only the owned completed
// doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed.
await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 });
// Only owner's own docs appear (received docs are on sender's team)
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0
await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team)
await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed
});
test('team context tab counts should be accurate with mixed documents', async ({ page }) => {
@@ -1060,7 +1070,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${team.url}/documents`,
});
await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 });
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'All', 4);
});
test('team with team email tab counts should include received documents', async ({ page }) => {
@@ -1094,9 +1107,11 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
redirectPath: `/t/${team.url}/documents`,
});
// Inbox = one pending doc received by team email (NOT_SIGNED), pending = own
// pending, completed = received completed via email, all = all of the above.
await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 });
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED)
await checkDocumentTabCount(page, 'Pending', 1); // Own pending
await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email
await checkDocumentTabCount(page, 'All', 4); // All of the above
});
});
@@ -1148,7 +1163,9 @@ test.describe('Find Documents UI - Sender Filter', () => {
await checkDocumentTabCount(page, 'All', 3);
// Filter by member1
await toggleDocumentSenderFilter(page, member1.name ?? '');
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: member1.name ?? '' }).click();
await page.waitForURL(/senderIds/);
// Should only show member1's doc
await checkDocumentTabCount(page, 'All', 1);
@@ -11,7 +11,7 @@ import {
assertRecipientRole,
getRecipientEmailInputs,
getRecipientRows,
getRecipientStepCards,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
setRecipientEmail,
setRecipientName,
@@ -34,14 +34,14 @@ const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
await assertRecipientRole(root, 1, 'Needs to sign');
await assertRecipientRole(root, 2, 'Receives copy');
// Only the two signers render as ordered group cards, showing groups 1 and 2.
await expect(getRecipientStepCards(root)).toHaveCount(2);
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(root.getByText('Group 2', { exact: true })).toBeVisible();
// Only the two signers have signing order inputs, showing 1 and 2.
await expect(getSigningOrderInputs(root)).toHaveCount(2);
await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2');
// The CC row itself renders outside the group cards with no drag handle.
// The CC row itself renders no signing order input (placeholder div instead).
const ccRow = getRecipientRows(root).nth(2);
await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).toHaveCount(0);
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
};
test.describe('document editor', () => {
@@ -61,8 +61,8 @@ test.describe('document editor', () => {
await setRecipientName(root, 1, CC_RECIPIENT.name);
await setRecipientRole(root, 1, 'Receives copy');
// Once the row becomes CC, it drops out of the ordered group cards.
await expect(getRecipientStepCards(root)).toHaveCount(1);
// Once the row becomes CC, its signing order input disappears.
await expect(getSigningOrderInputs(root)).toHaveCount(1);
// Add signer B third. The new row is inserted before the CC recipient,
// which is kept last by the client-side sorting.
@@ -1,137 +0,0 @@
import { prisma } from '@documenso/prisma';
import { expect, test } from '@playwright/test';
import {
clickAddSignerButton,
dragGroupCardOntoCard,
dragRecipientRowToGap,
getRecipientEmailInputs,
getRecipientStepCards,
moveGroupCardUp,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
const { envelopeId } = surface;
if (!envelopeId) {
throw new Error('Expected surface to have an envelope ID');
}
await expect
.poll(
async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
},
{ timeout: 15_000 },
)
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
};
const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await setRecipientName(root, 0, 'Alice');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 2, 'carol@example.com');
await toggleSigningOrder(root, true);
// Three standalone groups.
await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
// Drag carol's card onto bob's card to merge them into one group.
await dragGroupCardOntoCard(root, 2, 1);
await expect(root.getByText('2 recipients · any order')).toBeVisible();
await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 2],
]);
// Groups survive a reload (grouped normalization on load).
await root.reload();
await expect(root.getByText('2 recipients · any order')).toBeVisible();
// Ungroup dissolves back into sequential groups.
await root.getByTestId('ungroup-step-button').click();
await expect(root.getByText('2 recipients · any order')).not.toBeVisible();
await expect(root.getByText('Group 3', { exact: true })).toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 3],
]);
// Drag bob's row into the gap after the last group, moving him to the end.
await dragRecipientRowToGap(root, 1, 3);
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 3],
['carol@example.com', 2],
]);
};
test.describe('document editor', () => {
test('documents: group recipients via drag and drop and ungroup', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runGroupingFlow(surface);
});
test('documents: reordered group cards can still be dragged', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await toggleSigningOrder(root, true);
await expect(getRecipientStepCards(root)).toHaveCount(2);
// Move bob's card into position 1.
await moveGroupCardUp(root, 1);
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com');
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com');
// Regression: after a reorder, the card moved into position 2 must still
// be draggable — positional drag-and-drop ids used to go stale on mounted
// cards, silently killing their drag handles. Prove it by completing a
// merge with the repositioned card.
await dragGroupCardOntoCard(root, 1, 0);
await expect(root.getByText('2 recipients · any order')).toBeVisible();
});
});
test.describe('template editor', () => {
test('templates: group recipients via drag and drop and ungroup', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runGroupingFlow(surface);
});
});
@@ -9,12 +9,11 @@ import {
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
dragRecipientRowToGap,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientNameInputs,
getRecipientRemoveButtons,
getRecipientStepCards,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
@@ -22,6 +21,7 @@ import {
setRecipientEmail,
setRecipientName,
setRecipientRole,
setSigningOrderValue,
type TEnvelopeEditorSurface,
toggleAllowDictateSigners,
toggleSigningOrder,
@@ -112,71 +112,46 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
// The role selects must reflect the change immediately, without requiring a
// navigation or reload (regression: leaf controllers going stale after a
// root-level signers array update).
await assertRecipientRole(surface.root, 1, 'Needs to approve');
await assertRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
// Reordering is drag-only. Pointer-emulated drags are unreliable inside the
// embedded authoring surface (its inner scroll container auto-scrolls and
// cancels the emulated drag), so the drag-swap is exercised on the native
// surfaces only — the same component drives all surfaces.
const shouldSwapViaDrag = !surface.isEmbedded;
if (shouldSwapViaDrag) {
// Let the debounced autosave from the edits above land before dragging —
// the editor re-rendering mid-drag would cancel the drag.
await surface.root.waitForTimeout(1500);
// Drag the first recipient's row into the gap after the last group,
// swapping the two.
await dragRecipientRowToGap(surface.root, 0, 2);
}
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root);
const [firstRecipient, secondRecipient] = shouldSwapViaDrag
? [TEST_RECIPIENT_VALUES.secondRecipient, primaryRecipient]
: [primaryRecipient, TEST_RECIPIENT_VALUES.secondRecipient];
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.email);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.email);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
await assertRecipientRole(surface.root, 0, shouldSwapViaDrag ? 'Needs to approve' : 'Needs to sign');
await assertRecipientRole(surface.root, 1, shouldSwapViaDrag ? 'Needs to sign' : 'Needs to approve');
await assertRecipientRole(surface.root, 0, 'Needs to approve');
await assertRecipientRole(surface.root, 1, 'Needs to sign');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible();
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
return {
externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [
{
email: firstRecipient.email,
name: firstRecipient.name,
role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER,
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
role: RecipientRole.APPROVER,
signingOrder: 1,
},
{
email: secondRecipient.email,
name: secondRecipient.name,
role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER,
email: primaryRecipient.email,
name: primaryRecipient.name,
role: RecipientRole.SIGNER,
signingOrder: 2,
},
],
+3 -108
View File
@@ -1,116 +1,11 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
type DocumentStatusCounts = {
inbox?: number;
pending?: number;
completed?: number;
draft?: number;
cancelled?: number;
rejected?: number;
expired?: number;
all?: number;
};
const STATUS_KEYS = {
inbox: 'INBOX',
pending: 'PENDING',
completed: 'COMPLETED',
draft: 'DRAFT',
cancelled: 'CANCELLED',
rejected: 'REJECTED',
expired: 'EXPIRED',
all: 'ALL',
} as const;
/**
* Check the counts for multiple document statuses in one go via the
* visually hidden stats rendered alongside the status filter.
*
* When `all` is provided the status filter is also cleared and the
* unfiltered table count (or empty state) is verified.
*/
export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => {
for (const [key, status] of Object.entries(STATUS_KEYS)) {
const count = counts[key as keyof typeof STATUS_KEYS];
if (count === undefined) {
continue;
}
await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString());
}
if (counts.all !== undefined) {
await clearDocumentStatusFilter(page);
if (counts.all === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`);
}
};
/**
* Select a status in the documents status filter pill.
*
* No-op if the status is already selected, since selecting the active
* option again would clear the filter.
*/
export const selectDocumentStatusFilter = async (page: Page, statusName: string) => {
const currentStatus = new URL(page.url()).searchParams.get('status');
if (currentStatus === statusName.toUpperCase()) {
return;
}
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: statusName }).click();
};
/**
* Toggle a sender in the documents sender filter pill.
*
* The sender filter is a multi select, so the popover stays open after
* picking and is closed with Escape.
*/
export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => {
await page.getByTestId('documents-table-sender-filter').click();
await page.getByRole('option', { name: senderName }).click();
await page.waitForURL(/senderIds/);
await page.keyboard.press('Escape');
};
/**
* Clear the documents status filter pill, returning to the "All" view.
*/
export const clearDocumentStatusFilter = async (page: Page) => {
const currentStatus = new URL(page.url()).searchParams.get('status');
if (!currentStatus) {
return;
}
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: 'Clear' }).click();
};
/**
* Apply a status filter (or 'All' to clear it) and verify both the hidden
* stats count and the resulting table.
*
* The count is not asserted against the stats for 'All', since tests use it
* with search queries applied which only the table respects.
*/
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
if (tabName === 'All') {
await clearDocumentStatusFilter(page);
} else {
await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString());
await page.getByRole('tab', { name: tabName }).click();
await selectDocumentStatusFilter(page, tabName);
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
@@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Locator, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { apiSignin } from './authentication';
@@ -264,6 +264,8 @@ export const getRecipientRows = (root: Page) =>
export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
@@ -333,208 +335,10 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) =>
}
};
/**
* Performs a mouse-based drag from a drag handle onto a target element.
*
* `@hello-pangea/dnd` only starts a drag once the pointer travels a small
* distance while pressed, and it hit-tests drop targets using the CENTRE of
* the dragged element — not the cursor. Since drag handles sit at the edge of
* wide rows/cards, the cursor destination is compensated so the dragged
* element's centre lands on the target's centre.
*/
export const dragHandleToTarget = async (
root: Page,
handle: Locator,
target: Locator,
options: { activeClass: string },
) => {
const { activeClass } = options;
await handle.scrollIntoViewIfNeeded();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error('Unable to resolve drag handle position');
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await root.mouse.move(startX, startY);
await root.mouse.down();
// Exceed the drag activation threshold, then wait for drag-dependent layout
// (e.g. expanding gap drop-zones) to settle before resolving positions.
const cursorX = startX + 8;
const cursorY = startY;
await root.mouse.move(cursorX, cursorY, { steps: 2 });
await root.waitForTimeout(300);
// The dragged element is the handle's draggable ancestor; while dragging it
// is fixed-positioned and follows the cursor at a constant offset. Drop
// targeting uses the dragged element's CENTRE, not the cursor, so the
// cursor destination is compensated by that offset.
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
const draggedBox = await draggedElement.boundingBox();
const targetBox = await target.boundingBox();
if (!draggedBox || !targetBox) {
await root.mouse.up();
throw new Error('Unable to resolve drag positions');
}
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
const hasBecomeActive = async () => {
const className = await target.getAttribute('class');
return Boolean(className?.includes(activeClass));
};
// The highlight class is rendered from the library's own drag state, so it
// cannot disagree with where a drop will land — both phases below only drop
// once the target reports the drag as over it AND that state survives a
// short confirmation dwell (it can flicker while crossing a card's
// reorder/combine boundary).
//
// The cursor is always clamped inside the viewport: moving outside the
// window cancels the drag (pointercancel), and holding near the bottom edge
// lets the library auto-scroll the target up to the cursor instead.
const viewportHeight = root.viewportSize()?.height ?? 720;
const maxCursorY = viewportHeight - 40;
const confirmAndDrop = async () => {
if (!(await hasBecomeActive())) {
return false;
}
await root.waitForTimeout(150);
if (!(await hasBecomeActive())) {
return false;
}
await root.mouse.up();
return true;
};
let hasDropped = false;
// Crawl-and-drop: approach from above and inch downward through the
// corridor. Captured drop-target geometry can drift a few pixels from the
// live layout for small targets, so a slow traversal is the reliable way to
// hit them.
const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX;
const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY);
await root.mouse.move(crawlX, crawlStartY, { steps: 15 });
await root.waitForTimeout(150);
for (let step = 1; step <= 80; step += 1) {
if (await confirmAndDrop()) {
hasDropped = true;
break;
}
await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 });
await root.waitForTimeout(70);
}
if (!hasDropped) {
await root.mouse.up();
}
await root.waitForTimeout(400);
};
export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]');
export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]');
export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]');
export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]');
/**
* Drags a whole group card onto another card, merging the two groups.
*
* Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are
* unreliable because approaching a card traverses its reorder edge, which
* displaces the target away from the cursor. Keyboard drags step through
* positions (including combine states) deterministically.
*/
export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => {
const handle = getStepDragHandles(root).nth(sourceCardIndex);
const target = getRecipientStepCards(root).nth(targetCardIndex);
await handle.scrollIntoViewIfNeeded();
await handle.focus();
// Lift.
await root.keyboard.press('Space');
await root.waitForTimeout(250);
const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown';
for (let press = 0; press < 4; press += 1) {
await root.keyboard.press(direction);
await root.waitForTimeout(250);
const targetClassName = await target.getAttribute('class');
if (targetClassName?.includes('ring-primary')) {
// Drop while the target reports the combine state.
await root.keyboard.press('Space');
await root.waitForTimeout(400);
return;
}
}
await root.keyboard.press('Escape');
throw new Error('Combine drag did not reach the target card');
};
/**
* Moves a group card one position up via keyboard drag. With combining
* enabled, the first ArrowUp enters the combine state with the card above and
* the second moves above it.
*/
export const moveGroupCardUp = async (root: Page, cardIndex: number) => {
const handle = getStepDragHandles(root).nth(cardIndex);
await handle.scrollIntoViewIfNeeded();
await handle.focus();
await root.keyboard.press('Space');
await root.waitForTimeout(250);
await root.keyboard.press('ArrowUp');
await root.waitForTimeout(250);
await root.keyboard.press('ArrowUp');
await root.waitForTimeout(250);
await root.keyboard.press('Space');
await root.waitForTimeout(400);
};
/**
* Drags a recipient row into a gap between group cards, extracting it into
* its own standalone group at that position.
*/
export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => {
await dragHandleToTarget(
root,
getRecipientRowDragHandles(root).nth(rowIndex),
getRecipientStepGaps(root).nth(gapIndex),
// The marker class applied to a gap drop-zone while dragged over.
{ activeClass: 'gap-active' },
);
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
@@ -1,95 +0,0 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client';
import { signSignaturePad } from '../fixtures/signature';
type SeededRecipient = Awaited<ReturnType<typeof seedPendingDocumentWithFullFields>>['recipients'][number];
const completeSigning = async (page: Page, recipient: SeededRecipient) => {
const signUrl = `/sign/${recipient.token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
for (const field of recipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
};
const expectWaiting = async (page: Page, token: string) => {
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}/waiting`);
};
test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => {
const { user, team } = await seedUser();
const { user: signer1 } = await seedUser();
const { user: signer2a } = await seedUser();
const { user: signer2b } = await seedUser();
const { user: signer3 } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signer1, signer2a, signer2b, signer3],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
const [recipient1, recipient2a, recipient2b, recipient3] = recipients;
// While step 1 is pending, both group members and step 3 are blocked.
await expectWaiting(page, recipient2a.token);
await expectWaiting(page, recipient2b.token);
await expectWaiting(page, recipient3.token);
await completeSigning(page, recipient1);
// The group is now active; step 3 is still blocked.
await expectWaiting(page, recipient3.token);
// Sign with the SECOND group member first to prove any-order signing.
await completeSigning(page, recipient2b);
// One group member remains — step 3 stays blocked.
await expectWaiting(page, recipient3.token);
await completeSigning(page, recipient2a);
// The whole group is done — step 3 unlocks and completes the document.
await completeSigning(page, recipient3);
await expect
.poll(async () => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
});
return envelope.status;
})
.toBe(DocumentStatus.COMPLETED);
});
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
@@ -20,13 +20,23 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 5);
// Apply filter.
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
// Check counts after filtering.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
}
@@ -105,13 +115,23 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 });
await checkDocumentTabCount(page, 'Inbox', 2);
await checkDocumentTabCount(page, 'Pending', 3);
await checkDocumentTabCount(page, 'Completed', 3);
await checkDocumentTabCount(page, 'Draft', 3);
await checkDocumentTabCount(page, 'All', 11);
// Apply filter.
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
// Check counts after filtering.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await apiSignout({ page });
}
@@ -182,13 +202,23 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 });
await checkDocumentTabCount(page, 'Inbox', 3);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 2);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 9);
// Apply filter.
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
// Check counts after filtering.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
});
test('[TEAMS]: resend pending team document', async ({ page }) => {
@@ -243,7 +273,11 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
@@ -282,7 +316,11 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
@@ -321,7 +359,11 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
});
// Check document counts.
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 });
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
await expect(page.getByText('2 selected')).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await expect(page.getByText('1 selected')).toBeVisible();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+2 -2
View File
@@ -18,9 +18,9 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.23.1",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0",
"tsx": "^4.23.1"
"pngjs": "^7.0.0"
},
"dependencies": {
"start-server-and-test": "^2.1.3"
@@ -1,178 +0,0 @@
import { Zip, ZipPassThrough } from 'fflate';
export type ZipFileEntry = {
/**
* The path of the file within the archive. Forward slashes create folders.
* Individual path segments should be sanitized with
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
*/
filename: string;
data: Blob;
};
/**
* Sanitizes a single path segment (folder or file name) for use inside a zip
* archive, replacing characters that are path separators or invalid on
* Windows extraction.
*/
export const sanitizeZipPathSegment = (segment: string): string => {
const sanitized = segment
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
.trim()
// Windows cannot extract folders or files ending with a dot.
.replace(/\.+$/, '');
return sanitized || 'untitled';
};
export type ZipWriter = {
/**
* Adds a file to the zip stream. Files are written incrementally so the
* input blob can be garbage collected once this resolves.
*/
addFile: (entry: ZipFileEntry) => Promise<void>;
/**
* Finishes the zip stream and returns the archive as a blob.
*/
finalize: () => Blob;
/**
* Discards the zip stream and any buffered output.
*/
abort: () => void;
};
/**
* How many bytes of a blob to materialise into the JS heap per read. Blobs
* (e.g. fetch responses) can be disk-backed by the browser, it is only
* `arrayBuffer()` that forces them into memory, so we read in slices.
*/
const READ_SLICE_BYTES = 4 * 1024 * 1024;
/**
* Once this many bytes of zip output have accumulated in the JS heap they are
* coalesced into an intermediate blob. Browsers can page blob storage to disk
* under memory pressure, and the final `new Blob(parts)` composes parts by
* reference, so this keeps the heap bounded regardless of archive size.
*/
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
/**
* Creates an incremental client-side zip writer.
*
* Files are stored without compression (PDFs are already internally
* compressed) and streamed through the archive as they are added, so peak JS
* heap usage is bounded by roughly one read slice plus one output buffer
* rather than the total size of the archive.
*/
export const createZipWriter = (): ZipWriter => {
const usedNames = new Set<string>();
const outputParts: Blob[] = [];
let pendingChunks: Uint8Array[] = [];
let pendingSize = 0;
let zipError: Error | null = null;
const flushPendingChunks = () => {
if (pendingChunks.length === 0) {
return;
}
outputParts.push(new Blob(pendingChunks));
pendingChunks = [];
pendingSize = 0;
};
// ZipPassThrough is synchronous (no workers), so output callbacks have
// always fired by the time `push`/`end` return.
const zipStream = new Zip((error, chunk, isFinal) => {
if (error) {
zipError = error;
return;
}
pendingChunks.push(chunk);
pendingSize += chunk.length;
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
flushPendingChunks();
}
});
/**
* Deduplicates filenames case-insensitively (Windows extraction is
* case-insensitive) by appending " (n)" before the extension.
*/
const deduplicateFilename = (filename: string) => {
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
const baseName = match?.[1] ?? filename;
const extension = match?.[2] ?? '';
let candidate = filename;
let counter = 1;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${baseName} (${counter})${extension}`;
counter += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
};
const addFile = async ({ filename, data }: ZipFileEntry) => {
if (zipError) {
throw zipError;
}
const file = new ZipPassThrough(deduplicateFilename(filename));
zipStream.add(file);
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
file.push(new Uint8Array(await slice.arrayBuffer()));
if (zipError) {
throw zipError;
}
}
file.push(new Uint8Array(0), true);
if (zipError) {
throw zipError;
}
};
const finalize = () => {
zipStream.end();
if (zipError) {
throw zipError;
}
flushPendingChunks();
return new Blob(outputParts, { type: 'application/zip' });
};
const abort = () => {
zipStream.terminate();
pendingChunks = [];
pendingSize = 0;
outputParts.length = 0;
};
return {
addFile,
finalize,
abort,
};
};
+3 -22
View File
@@ -32,11 +32,7 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
}
};
/**
* Fetches a PDF for an envelope item and returns it as a blob alongside the
* filename it should be saved as. Throws on non-OK responses.
*/
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -44,27 +40,12 @@ export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signe
version,
});
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download PDF: ${response.status}`);
}
const blob = await response.blob();
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
return {
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
blob,
};
};
export const downloadPDF = async (options: DownloadPDFProps) => {
const { filename, blob } = await fetchPDF(options);
downloadFile({
filename,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
@@ -9,8 +9,7 @@ import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
@@ -66,71 +65,10 @@ export const ZEditorRecipientsFormSchema = z
});
}
});
const seenSigningOrders = new Set<number>();
data.signers.forEach((signer, index) => {
if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') {
return;
}
if (seenSigningOrders.has(signer.signingOrder)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support recipient signing groups.',
path: ['signers', index, 'signingOrder'],
});
}
seenSigningOrders.add(signer.signingOrder);
});
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
/**
* Replaces the signers array while keeping controlled inputs in sync.
*
* Rows are rendered with stable `formId` keys (required for drag and drop),
* so react-hook-form `Controller`s never remount and their leaf
* subscriptions are NOT re-notified by a root-level array `setValue`. Any
* value that changes while a signer keeps its index (e.g. a role change)
* must be leaf-set first so the controlled input actually re-renders.
*/
export const updateEditorSigners = (
form: UseFormReturn<TEditorRecipientsFormSchema>,
updatedSigners: TEditorRecipientsFormSchema['signers'],
) => {
const previousSigners = form.getValues('signers');
updatedSigners.forEach((signer, index) => {
const previousSigner = previousSigners[index];
// Only slot-stable signers need leaf notifications — moved signers get a
// new field name and re-subscribe with fresh values on their own.
if (!previousSigner || previousSigner.formId !== signer.formId) {
return;
}
if (previousSigner.role !== signer.role) {
form.setValue(`signers.${index}.role`, signer.role, { shouldDirty: true });
}
if (previousSigner.email !== signer.email) {
form.setValue(`signers.${index}.email`, signer.email, { shouldDirty: true });
}
if (previousSigner.name !== signer.name) {
form.setValue(`signers.${index}.name`, signer.name, { shouldDirty: true });
}
});
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
};
type EditorRecipientsProps = {
envelope: TEditorEnvelope;
};
@@ -163,7 +101,7 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
const signers: TLocalRecipient[] =
formRecipients.length > 0
? normalizeGroupedSigningOrders(sortRecipientsForSigningOrder(formRecipients))
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
: [
{
formId: initialId,
@@ -25,7 +25,6 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -398,87 +397,65 @@ export const completeDocumentWithToken = async ({
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// The next group: every pending recipient sharing the lowest pending
// signing order. If the completing recipient's own step is still
// pending (a group peer has not signed yet), the flow does not advance —
// the remaining peers were already activated when their step unlocked.
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
const [nextRecipient] = pendingRecipients;
const currentRecipientOrder = recipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
const hasCompletedCurrentStep = nextGroup.every(
(pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder,
);
if (nextGroup.length > 0 && hasCompletedCurrentStep) {
// Dictation only applies when advancing to a single-recipient step.
const canDictateNextSigner =
Boolean(nextSigner) && Boolean(envelope.documentMeta?.allowDictateNextSigner) && nextGroup.length === 1;
await prisma.$transaction(async (tx) => {
if (canDictateNextSigner && nextSigner) {
const [nextRecipient] = nextGroup;
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
for (const nextRecipient of nextGroup) {
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(canDictateNextSigner && nextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
await prisma.$transaction(async (tx) => {
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
});
}
});
for (const nextRecipient of nextGroup) {
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
}
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
@@ -38,7 +38,6 @@ import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
@@ -151,11 +150,10 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
let recipientsToNotify = envelope.recipients;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active signing group. Recipients sharing the lowest
// pending signing order act in parallel within their group.
recipientsToNotify = filterRecipientsInFirstSigningGroup(
envelope.recipients.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC),
);
// Get the currently active recipient.
recipientsToNotify = envelope.recipients
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
.slice(0, 1);
}
if (envelope.envelopeItems.length === 0) {
@@ -5,14 +5,13 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@@ -195,6 +194,9 @@ export const getEnvelopeForRecipientSigning = async ({
},
},
},
orderBy: {
signingOrder: 'asc',
},
},
envelopeItems: true,
team: {
@@ -258,9 +260,23 @@ export const getEnvelopeForRecipientSigning = async ({
},
});
const isRecipientsTurn =
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
isRecipientTurnBySigningOrder(envelope.recipients, recipient);
let isRecipientsTurn = true;
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token);
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (envelope.recipients[i].role === RecipientRole.CC) {
continue;
}
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
isRecipientsTurn = false;
break;
}
}
}
const sender = settings.includeSenderDetails
? {
@@ -1,7 +1,5 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
export type GetIsRecipientTurnOptions = {
token: string;
@@ -19,7 +17,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
},
include: {
documentMeta: true,
recipients: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
},
});
@@ -27,11 +29,24 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
return true;
}
const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
const { recipients } = envelope;
if (!currentRecipient) {
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
return false;
}
return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (recipients[i].role === RecipientRole.CC) {
continue;
}
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
return false;
}
}
return true;
}
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
import { getDictatableNextRecipient } from '../../utils/recipient-groups';
export const getNextPendingRecipient = async ({
documentId,
@@ -17,17 +16,33 @@ export const getNextPendingRecipient = async ({
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
// CC recipients are informational only and never take part in signing,
// so they must never be offered as the next pending recipient.
role: {
not: RecipientRole.CC,
},
},
orderBy: [
{
signingOrder: {
sort: 'asc',
nulls: 'last',
},
},
{
id: 'asc',
},
],
});
const nextRecipient = getDictatableNextRecipient({ recipients, currentRecipientId });
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
if (!nextRecipient) {
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
return null;
}
return {
...nextRecipient,
...recipients[currentIndex + 1],
token: '',
};
};
@@ -23,13 +23,9 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis
let recipients = await prisma.recipient.findMany({
where: {
envelopeId: assistant.envelopeId,
OR: [
// The assistant themself — they may have fields of their own.
{ id: assistant.id },
// Grouped assistants only assist strictly later steps, never their
// own group peers.
{ signingOrder: { gt: assistant.signingOrder ?? 0 } },
],
signingOrder: {
gte: assistant.signingOrder ?? 0,
},
},
include: {
fields: {
@@ -40,7 +40,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
@@ -695,10 +694,7 @@ export const createDocumentFromDirectTemplate = async ({
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
// Dictation only applies when the next step is a single recipient.
const nextRecipient = nextGroup.length === 1 ? nextGroup[0] : null;
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
-466
View File
@@ -1,466 +0,0 @@
import { RecipientRole, SigningStatus } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import {
extractRecipientToNewStep,
filterRecipientsInFirstSigningGroup,
getDictatableNextRecipient,
groupRecipientsBySigningOrder,
isRecipientTurnBySigningOrder,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from './recipient-groups';
describe('groupRecipientsBySigningOrder', () => {
it('groups non-CC recipients sharing a signing order into steps', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.APPROVER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
expect(ccRecipients).toEqual([]);
expect(steps.map((step) => step.order)).toEqual([1, 2, 3]);
expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['b', 'c'], ['d']]);
});
it('excludes CC recipients from steps', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.CC, signingOrder: undefined },
];
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
expect(steps).toHaveLength(1);
expect(ccRecipients.map((r) => r.formId)).toEqual(['b']);
});
it('sorts steps by order regardless of input order and keeps member input order', () => {
const recipients = [
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const { steps } = groupRecipientsBySigningOrder(recipients);
expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['c', 'b']]);
});
it('collects recipients without a signing order into a single tail step', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: undefined },
];
const { steps } = groupRecipientsBySigningOrder(recipients);
expect(steps).toHaveLength(2);
expect(steps[1].members.map((m) => m.formId)).toEqual(['b', 'c']);
});
});
describe('normalizeGroupedSigningOrders', () => {
it('preserves groups while compacting gaps to dense step numbers', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 5 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 9 },
];
expect(normalizeGroupedSigningOrders(recipients).map((r) => r.signingOrder)).toEqual([1, 2, 2, 3]);
});
it('moves CC recipients to the tail with an undefined signing order', () => {
const recipients = [
{ formId: 'cc', role: RecipientRole.CC, signingOrder: 1 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const normalized = normalizeGroupedSigningOrders(recipients);
expect(normalized.map((r) => r.formId)).toEqual(['a', 'b', 'cc']);
expect(normalized.map((r) => r.signingOrder)).toEqual([1, 1, undefined]);
});
it('anchors steps containing locked recipients to their persisted order', () => {
const recipients = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 4 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 4 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['locked', 1],
['a', 2],
['b', 2],
]);
});
it('never renumbers an editable step onto a locked step number', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
// 'b' must skip the reserved locked number 2 and take 3, not collide into 2.
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['a', 1],
['locked', 2],
['b', 3],
]);
});
it('keeps a group intact when it contains the locked recipient', () => {
const recipients = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 7 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['locked', 2],
['peer', 2],
['a', 3],
]);
});
});
const makeSigners = () => [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 4 },
];
const ordersOf = (signers: Array<{ formId: string; signingOrder?: number }>) =>
signers.map((signer) => [signer.formId, signer.signingOrder]);
describe('mergeSteps', () => {
it('merges all members of the source step into the target step', () => {
const merged = mergeSteps(makeSigners(), 2, 1);
expect(ordersOf(merged)).toEqual([
['a', 1],
['b', 2],
['c', 2],
['d', 3],
]);
});
it('merges a whole group into another step', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const merged = mergeSteps(signers, 1, 2);
expect(ordersOf(merged)).toEqual([
['a', 1],
['d', 2],
['b', 2],
['c', 2],
]);
});
it('returns the input unchanged for an invalid step index', () => {
const signers = makeSigners();
expect(mergeSteps(signers, 7, 1)).toEqual(signers);
});
});
describe('moveRecipientToStep', () => {
it('appends the recipient to the target step members', () => {
const moved = moveRecipientToStep(makeSigners(), 'a', 2);
expect(ordersOf(moved)).toEqual([
['b', 1],
['c', 2],
['a', 2],
['d', 3],
]);
});
it('dissolves a group of two when one member joins another step', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const moved = moveRecipientToStep(signers, 'b', 1);
expect(ordersOf(moved)).toEqual([
['a', 1],
['c', 2],
['b', 2],
]);
});
it('is a no-op when the recipient is already a member of the target step', () => {
const signers = makeSigners();
expect(ordersOf(moveRecipientToStep(signers, 'b', 1))).toEqual(ordersOf(signers));
});
});
describe('extractRecipientToNewStep', () => {
it('extracts a group member into its own step at the given gap', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
// Gap 2 = before the step containing 'd'.
const extracted = extractRecipientToNewStep(signers, 'c', 2);
expect(ordersOf(extracted)).toEqual([
['a', 1],
['b', 2],
['c', 3],
['d', 4],
]);
});
it('extracts to the end for an out-of-bounds gap index', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const extracted = extractRecipientToNewStep(signers, 'a', 99);
expect(ordersOf(extracted)).toEqual([
['b', 1],
['c', 2],
['a', 3],
]);
});
it('is a no-op when a solo recipient is dropped into an adjacent gap', () => {
const signers = makeSigners();
expect(ordersOf(extractRecipientToNewStep(signers, 'b', 1))).toEqual(ordersOf(signers));
expect(ordersOf(extractRecipientToNewStep(signers, 'b', 2))).toEqual(ordersOf(signers));
});
});
describe('reorderStep', () => {
it('moves a whole group to a new position', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const reordered = reorderStep(signers, 1, 2);
expect(ordersOf(reordered)).toEqual([
['a', 1],
['d', 2],
['b', 3],
['c', 3],
]);
});
it('keeps a locked step number anchored while others flow around it', () => {
const signers = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const reordered = reorderStep(signers, 1, 2, (r) => r.formId !== 'locked');
expect(ordersOf(reordered)).toEqual([
['locked', 1],
['c', 2],
['b', 3],
]);
});
});
describe('ungroupStep', () => {
it('splits a group into consecutive standalone steps preserving relative order', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const ungrouped = ungroupStep(signers, 1);
expect(ordersOf(ungrouped)).toEqual([
['a', 1],
['b', 2],
['c', 3],
['d', 4],
]);
});
it('is a no-op on a step with a single member', () => {
const signers = makeSigners();
expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers));
});
});
describe('isRecipientTurnBySigningOrder', () => {
const recipient = (
id: number,
signingOrder: number | null,
signingStatus: SigningStatus,
role: RecipientRole = RecipientRole.SIGNER,
) => ({ id, signingOrder, signingStatus, role });
it('allows both members of the active group regardless of member order', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
recipient(4, 3, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
});
it('blocks later steps until every group member has signed', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
recipient(4, 3, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
});
it('treats a rejected recipient in an earlier step as blocking', () => {
const recipients = [recipient(1, 1, SigningStatus.REJECTED), recipient(2, 2, SigningStatus.NOT_SIGNED)];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(false);
});
it('ignores CC recipients entirely', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC),
recipient(2, 2, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
});
it('treats recipients without a signing order as a parallel tail group', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, null, SigningStatus.NOT_SIGNED),
recipient(3, null, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
});
});
describe('filterRecipientsInFirstSigningGroup', () => {
it('returns every pending recipient sharing the lowest order', () => {
const pending = [
{ id: 3, signingOrder: 2 },
{ id: 4, signingOrder: 2 },
{ id: 5, signingOrder: 3 },
];
expect(filterRecipientsInFirstSigningGroup(pending).map((r) => r.id)).toEqual([3, 4]);
});
it('returns an empty array for no pending recipients', () => {
expect(filterRecipientsInFirstSigningGroup([])).toEqual([]);
});
});
describe('getDictatableNextRecipient', () => {
const recipient = (
id: number,
signingOrder: number | null,
signingStatus: SigningStatus,
role: RecipientRole = RecipientRole.SIGNER,
) => ({ id, signingOrder, signingStatus, role });
it('returns the next recipient when current is last of their step and next step is a single recipient', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 3, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
});
it('returns null while a group peer is still unsigned', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, 1, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
});
it('returns the next single recipient once all group peers signed', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 1, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
});
it('returns null when the next step is a group', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
});
it('returns null when there is no later step, for CC targets, or unknown recipients', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, null, SigningStatus.NOT_SIGNED, RecipientRole.CC),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 999 })).toBeNull();
});
});
-384
View File
@@ -1,384 +0,0 @@
import type { Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { isCcRecipient } from './recipients';
/**
* A recipient "step" is the set of non-CC recipients sharing a signing order.
* A step with 2 or more members is a "signing group": members may act in any
* order among themselves, and the next step only unlocks once every member of
* the group has completed their action.
*/
type GroupableRecipient = Pick<Recipient, 'role'> & {
signingOrder?: number | null;
};
export type RecipientStep<T> = {
/**
* The signing order shared by all members of the step.
*/
order: number;
members: T[];
};
const UNORDERED = Number.MAX_SAFE_INTEGER;
const effectiveOrder = (recipient: { signingOrder?: number | null }) => recipient.signingOrder ?? UNORDERED;
/**
* Derives the ordered list of steps from a list of recipients.
*
* - Non-CC recipients sharing a signing order form one step.
* - Recipients without a signing order share a single tail step.
* - CC recipients are returned separately and never belong to a step.
*/
export const groupRecipientsBySigningOrder = <T extends GroupableRecipient>(recipients: T[]) => {
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const membersByOrder = new Map<number, T[]>();
for (const recipient of nonCcRecipients) {
const order = effectiveOrder(recipient);
const members = membersByOrder.get(order) ?? [];
members.push(recipient);
membersByOrder.set(order, members);
}
const steps: RecipientStep<T>[] = [...membersByOrder.entries()]
.sort(([orderA], [orderB]) => orderA - orderB)
.map(([order, members]) => ({ order, members }));
return { steps, ccRecipients };
};
/**
* Dense-renumbers steps to 1..K while preserving groups (duplicate orders).
*
* Steps containing a locked recipient (per `canUpdateRecipient`) keep the
* locked recipient's persisted order, and editable steps never collide into a
* locked step's number.
*
* CC recipients get an undefined signing order and move to the tail. The
* returned array is re-ordered by step sequence.
*/
export const normalizeGroupedSigningOrders = <T extends GroupableRecipient>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
const lockedOrderByStepIndex = new Map<number, number>();
steps.forEach((step, index) => {
const lockedMember = step.members.find((member) => !canUpdateRecipient(member));
if (lockedMember && typeof lockedMember.signingOrder === 'number') {
lockedOrderByStepIndex.set(index, lockedMember.signingOrder);
}
});
const reservedOrders = new Set(lockedOrderByStepIndex.values());
const normalizedSteps: RecipientStep<T>[] = [];
let nextOrder = 1;
steps.forEach((step, index) => {
const lockedOrder = lockedOrderByStepIndex.get(index);
if (lockedOrder !== undefined) {
normalizedSteps.push({ order: lockedOrder, members: step.members });
nextOrder = Math.max(nextOrder, lockedOrder + 1);
return;
}
while (reservedOrders.has(nextOrder)) {
nextOrder += 1;
}
normalizedSteps.push({ order: nextOrder, members: step.members });
nextOrder += 1;
});
return [
...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))),
...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })),
];
};
type EditorRecipient = GroupableRecipient & { formId: string };
/**
* Merges all members of the source step into the target step.
*/
export const mergeSteps = <T extends EditorRecipient>(
recipients: T[],
sourceStepIndex: number,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const sourceStep = steps[sourceStepIndex];
const targetStep = steps[targetStepIndex];
if (!sourceStep || !targetStep || sourceStepIndex === targetStepIndex) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId));
// Source members join after the target step's existing members.
const remaining = recipients.filter((recipient) => !sourceFormIds.has(recipient.formId));
const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId;
const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId);
const movedMembers = sourceStep.members.map((member) => ({ ...member, signingOrder: targetStep.order }));
const updated = [
...remaining.slice(0, insertAfterIndex + 1),
...movedMembers,
...remaining.slice(insertAfterIndex + 1),
];
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Moves a single recipient into the target step (joins the group).
*/
export const moveRecipientToStep = <T extends EditorRecipient>(
recipients: T[],
formId: string,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const targetStep = steps[targetStepIndex];
const mover = recipients.find((recipient) => recipient.formId === formId);
if (!targetStep || !mover || isCcRecipient(mover)) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
if (targetStep.members.some((member) => member.formId === formId)) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const remaining = recipients.filter((recipient) => recipient.formId !== formId);
const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId;
const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId);
const updated = [
...remaining.slice(0, insertAfterIndex + 1),
{ ...mover, signingOrder: targetStep.order },
...remaining.slice(insertAfterIndex + 1),
];
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Extracts a recipient into its own standalone step at the given gap position
* (gap N sits before step N; an out-of-bounds gap appends to the end).
*/
export const extractRecipientToNewStep = <T extends EditorRecipient>(
recipients: T[],
formId: string,
insertStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const mover = recipients.find((recipient) => recipient.formId === formId);
if (!mover || isCcRecipient(mover)) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId));
const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1;
// Dropping a solo step into the gap directly above or below itself is a no-op.
if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const insertOrder =
insertStepIndex >= steps.length ? (steps[steps.length - 1]?.order ?? 0) + 1 : steps[insertStepIndex].order - 0.5;
const updated = recipients.map((recipient) =>
recipient.formId === formId ? { ...recipient, signingOrder: insertOrder } : recipient,
);
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Moves a whole step (group) to a new position in the step sequence.
*
* Locked steps keep their members' persisted orders untouched (the sequence
* flows around them), and editable steps never collide onto a locked anchor —
* that would accidentally merge them during re-derivation.
*/
export const reorderStep = <T extends EditorRecipient>(
recipients: T[],
fromStepIndex: number,
toStepIndex: number,
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
if (!steps[fromStepIndex] || fromStepIndex === toStepIndex) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const reorderedSteps = [...steps];
const [movedStep] = reorderedSteps.splice(fromStepIndex, 1);
reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep);
const isStepLocked = (step: RecipientStep<T>) => step.members.some((member) => !canUpdateRecipient(member));
const lockedAnchors = new Set(reorderedSteps.filter((step) => isStepLocked(step)).map((step) => step.order));
const updated = [
...reorderedSteps.flatMap((step, index) => {
if (isStepLocked(step)) {
return step.members;
}
const tempOrder = lockedAnchors.has(index + 1) ? index + 1.5 : index + 1;
return step.members.map((member) => ({ ...member, signingOrder: tempOrder }));
}),
...ccRecipients,
];
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
type SignableRecipient = Pick<Recipient, 'role' | 'signingStatus'> & {
signingOrder?: number | null;
};
/**
* Whether it is the recipient's turn to act under SEQUENTIAL signing.
*
* A recipient may act iff no non-CC recipient with a strictly lower signing
* order is still unsigned (rejected counts as unsigned/blocking). Recipients
* sharing a signing order never block each other.
*
* Callers are responsible for checking the document is in SEQUENTIAL mode.
*/
export const isRecipientTurnBySigningOrder = <T extends SignableRecipient>(
recipients: T[],
currentRecipient: { signingOrder?: number | null },
): boolean => {
const currentOrder = effectiveOrder(currentRecipient);
return !recipients.some(
(recipient) =>
!isCcRecipient(recipient) &&
recipient.signingStatus !== SigningStatus.SIGNED &&
effectiveOrder(recipient) < currentOrder,
);
};
/**
* Returns every pending recipient sharing the lowest pending signing order —
* the "active group". Callers pass an already-filtered pending list.
*/
export const filterRecipientsInFirstSigningGroup = <T extends { signingOrder?: number | null }>(
pendingRecipients: T[],
): T[] => {
if (pendingRecipients.length === 0) {
return [];
}
const minOrder = Math.min(...pendingRecipients.map((recipient) => effectiveOrder(recipient)));
return pendingRecipients.filter((recipient) => effectiveOrder(recipient) === minOrder);
};
/**
* The single recipient that the current recipient may dictate (rename) on
* completion, or null when dictation does not apply:
*
* - the current recipient must be the last unsigned member of their step, and
* - the next step must contain exactly one recipient.
*/
export const getDictatableNextRecipient = <T extends SignableRecipient & Pick<Recipient, 'id'>>({
recipients,
currentRecipientId,
}: {
recipients: T[];
currentRecipientId: number;
}): T | null => {
const currentRecipient = recipients.find((recipient) => recipient.id === currentRecipientId);
if (!currentRecipient || isCcRecipient(currentRecipient)) {
return null;
}
const currentOrder = effectiveOrder(currentRecipient);
const hasUnsignedPeers = recipients.some(
(recipient) =>
recipient.id !== currentRecipientId &&
!isCcRecipient(recipient) &&
effectiveOrder(recipient) === currentOrder &&
recipient.signingStatus !== SigningStatus.SIGNED,
);
if (hasUnsignedPeers) {
return null;
}
const laterRecipients = recipients.filter(
(recipient) => !isCcRecipient(recipient) && effectiveOrder(recipient) > currentOrder,
);
const nextStep = filterRecipientsInFirstSigningGroup(laterRecipients);
if (nextStep.length !== 1) {
return null;
}
return nextStep[0];
};
/**
* Dissolves a group into consecutive standalone steps preserving relative order.
*/
export const ungroupStep = <T extends EditorRecipient>(
recipients: T[],
stepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const step = steps[stepIndex];
if (!step || step.members.length < 2) {
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
}
const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index]));
const updated = recipients.map((recipient) => {
const offset = offsetByFormId.get(recipient.formId);
if (offset === undefined) {
return recipient;
}
return { ...recipient, signingOrder: step.order + offset / (step.members.length + 1) };
});
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
-18
View File
@@ -51,24 +51,6 @@ describe('recipient signing order helpers', () => {
]);
});
it('detects an assistant anywhere in the last signing step (groups)', () => {
expect(
isAssistantLastSigner([
{ role: RecipientRole.SIGNER, signingOrder: 1 },
{ role: RecipientRole.ASSISTANT, signingOrder: 2 },
{ role: RecipientRole.SIGNER, signingOrder: 2 },
]),
).toBe(true);
expect(
isAssistantLastSigner([
{ role: RecipientRole.ASSISTANT, signingOrder: 1 },
{ role: RecipientRole.SIGNER, signingOrder: 1 },
{ role: RecipientRole.SIGNER, signingOrder: 2 },
]),
).toBe(false);
});
it('checks whether the last non-CC recipient is an assistant', () => {
expect(
isAssistantLastSigner([
+4 -52
View File
@@ -1,10 +1,9 @@
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
import type { TEditorEnvelope } from '../types/envelope-editor';
import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod';
@@ -23,32 +22,11 @@ export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
return recipient.role === RecipientRole.CC;
};
/**
* Whether an assistant sits in the last signing step (nobody after them to assist).
*
* Falls back to a positional check when no recipient carries a signing order.
*/
export const isAssistantLastSigner = (
recipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>,
) => {
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
if (nonCcRecipients.length === 0) {
return false;
}
const hasAnySigningOrder = nonCcRecipients.some((recipient) => typeof recipient.signingOrder === 'number');
if (!hasAnySigningOrder) {
return nonCcRecipients[nonCcRecipients.length - 1]?.role === RecipientRole.ASSISTANT;
}
const maxOrder = Math.max(...nonCcRecipients.map((recipient) => recipient.signingOrder ?? Number.MAX_SAFE_INTEGER));
return nonCcRecipients.some(
(recipient) =>
(recipient.signingOrder ?? Number.MAX_SAFE_INTEGER) === maxOrder && recipient.role === RecipientRole.ASSISTANT,
);
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
};
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
@@ -142,32 +120,6 @@ export const canRecipientBeModified = (
return true;
};
/**
* Editor-level wrapper around `canRecipientBeModified`.
*
* Template recipients and unsaved (id-less) recipients can always be modified.
*/
export const canEditorRecipientBeModified = (
envelope: Pick<TEditorEnvelope, 'type' | 'recipients' | 'fields'>,
recipientId?: number,
) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = envelope.recipients.find((r) => r.id === recipientId);
if (!recipient) {
return false;
}
return canRecipientBeModified(recipient, envelope.fields);
};
/**
* Whether a recipient can have their fields modified by the document owner.
*
+2 -5
View File
@@ -310,9 +310,6 @@ export const seedDraftDocument = async (
const documentId = await incrementDocumentId();
const envelopeTitle =
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
const document = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -323,12 +320,12 @@ export const seedDraftDocument = async (
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
teamId,
title: envelopeTitle,
title: `[TEST] Document ${key} - Draft`,
status: DocumentStatus.DRAFT,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: envelopeTitle,
title: `[TEST] Document ${key} - Draft`,
documentDataId: documentData.id,
order: 1,
},
@@ -44,13 +44,10 @@ export const signEnvelopeFieldRoute = procedure
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
OR: [
// The assistant's own fields.
{ id: recipient.id },
// Fields of recipients in strictly later steps only.
{ signingOrder: { gt: recipient.signingOrder ?? 0 } },
],
}
: {
id: recipient.id,
+1 -40
View File
@@ -35,43 +35,4 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
/**
* A segmented-control style radio group where each item renders as a small
* toggle button rather than a radio circle.
*/
const RadioGroupSegmented = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5', className)}
{...props}
ref={ref}
/>
);
});
RadioGroupSegmented.displayName = 'RadioGroupSegmented';
const RadioGroupSegmentedItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'rounded-sm px-2 py-0.5 font-medium text-muted-foreground text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
className,
)}
{...props}
>
{children}
</RadioGroupPrimitive.Item>
);
});
RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem';
export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };
export { RadioGroup, RadioGroupItem };