mirror of
https://github.com/documenso/documenso.git
synced 2026-08-16 03:21:55 +10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f9a1c3e5 | ||
|
|
7cdc423c42 | ||
|
|
34f6102a3f | ||
|
|
6a1236cac2 | ||
|
|
f47c9a7402 | ||
|
|
635332da2c | ||
|
|
83eb53fffb | ||
|
|
9db2014b5f | ||
|
|
e0d8146a93 | ||
|
|
fc68e31d3d | ||
|
|
cf26f30330 | ||
|
|
cf78dc9d10 | ||
|
|
b852e36f3c | ||
|
|
119b77b508 | ||
|
|
ba1e720dcc | ||
|
|
8d48c92a27 | ||
|
|
19514b182b | ||
|
|
129833d6bb | ||
|
|
e8484c4405 | ||
|
|
bbe03bb83d | ||
|
|
9c2f28a4f4 | ||
|
|
d52000f648 | ||
|
|
74c1752853 | ||
|
|
9d0e61716e | ||
|
|
8d82b1fae8 | ||
|
|
03809d3231 | ||
|
|
dbac6a787a |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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,7 @@ 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,
|
||||
@@ -223,27 +224,12 @@ export const DirectTemplateSigningForm = ({
|
||||
return 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;
|
||||
return (
|
||||
getDictatableNextRecipient({
|
||||
recipients: template.recipients,
|
||||
currentRecipientId: directRecipient.id,
|
||||
}) ?? undefined
|
||||
);
|
||||
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
|
||||
|
||||
return (
|
||||
|
||||
+3
-22
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -143,31 +144,11 @@ export const DocumentSigningPageViewV1 = ({
|
||||
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
|
||||
return 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;
|
||||
return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -290,32 +291,14 @@ export const EnvelopeSigningProvider = ({
|
||||
.filter((field) => field.inserted);
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
return getDictatableNextRecipient({
|
||||
recipients: envelope.recipients,
|
||||
currentRecipientId: recipient.id,
|
||||
});
|
||||
|
||||
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 (
|
||||
|
||||
+36
-528
@@ -1,42 +1,30 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
import {
|
||||
updateEditorSigners,
|
||||
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 {
|
||||
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 { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
|
||||
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
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, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } 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, 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 { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
@@ -46,6 +34,8 @@ 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();
|
||||
@@ -53,7 +43,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { sessionData } = useOptionalSession();
|
||||
@@ -61,7 +50,6 @@ 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
|
||||
@@ -107,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
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 { recipients } = envelope;
|
||||
|
||||
const { form } = editorRecipients;
|
||||
|
||||
@@ -161,17 +134,20 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||
};
|
||||
|
||||
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||
|
||||
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||
// 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({
|
||||
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,
|
||||
@@ -183,39 +159,22 @@ 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) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
(signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasDocumentBeenSent = recipients.some(
|
||||
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
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 canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
|
||||
|
||||
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
updateEditorSigners(form, updatedSigners);
|
||||
|
||||
if (shouldFocus) {
|
||||
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||
@@ -233,7 +192,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
signingOrder: stepCount + 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -245,8 +204,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) {
|
||||
form.setValue(
|
||||
'signers',
|
||||
updateEditorSigners(
|
||||
form,
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
@@ -255,10 +214,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -285,10 +240,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
updateEditorSigners(form, normalizeSigningOrders(currentSigners));
|
||||
|
||||
toast({
|
||||
title: plural(detectedRecipients.length, {
|
||||
@@ -302,32 +254,6 @@ 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 ?? '', {
|
||||
@@ -348,7 +274,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
email: currentEditorEmail ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: activeRecipientCount + 1,
|
||||
signingOrder: stepCount + 1,
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -357,142 +283,6 @@ 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);
|
||||
|
||||
@@ -504,10 +294,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
})),
|
||||
);
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
updateEditorSigners(form, updatedSigners);
|
||||
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
@@ -588,7 +376,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}, [formValues]);
|
||||
|
||||
const recipientCountLimit = organisation.organisationClaim.recipientCount;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
|
||||
const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
|
||||
|
||||
return (
|
||||
<Card backdropBlur={false} className="border">
|
||||
@@ -644,7 +432,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<PlusIcon className="mr-1 -ml-1 h-5 w-5" />
|
||||
@@ -794,287 +582,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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);
|
||||
@@ -0,0 +1,247 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertRecipientRole,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientRows,
|
||||
getSigningOrderInputs,
|
||||
getRecipientStepCards,
|
||||
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 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');
|
||||
// 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();
|
||||
|
||||
// The CC row itself renders no signing order input (placeholder div instead).
|
||||
// The CC row itself renders outside the group cards with no drag handle.
|
||||
const ccRow = getRecipientRows(root).nth(2);
|
||||
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
|
||||
await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).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, its signing order input disappears.
|
||||
await expect(getSigningOrderInputs(root)).toHaveCount(1);
|
||||
// Once the row becomes CC, it drops out of the ordered group cards.
|
||||
await expect(getRecipientStepCards(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.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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,11 +9,12 @@ import {
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
dragRecipientRowToGap,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getSigningOrderInputs,
|
||||
getRecipientStepCards,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
type TEnvelopeEditorSurface,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
@@ -112,46 +112,71 @@ 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(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||
await setSigningOrderValue(surface.root, 0, 2);
|
||||
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 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(TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.email);
|
||||
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
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 expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||
await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible();
|
||||
await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible();
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
email: firstRecipient.email,
|
||||
name: firstRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
email: secondRecipient.email,
|
||||
name: secondRecipient.name,
|
||||
role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 { Page } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
@@ -264,8 +264,6 @@ 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();
|
||||
@@ -335,10 +333,208 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) =>
|
||||
}
|
||||
};
|
||||
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
/**
|
||||
* 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 persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
|
||||
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
|
||||
const LocalRecipientSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
@@ -65,10 +66,71 @@ 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;
|
||||
};
|
||||
@@ -101,7 +163,7 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
|
||||
|
||||
const signers: TLocalRecipient[] =
|
||||
formRecipients.length > 0
|
||||
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||
? normalizeGroupedSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
@@ -397,65 +398,87 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
// 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);
|
||||
|
||||
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,
|
||||
},
|
||||
requestMetadata,
|
||||
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: {
|
||||
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,
|
||||
},
|
||||
],
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(canDictateNextSigner && nextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const nextRecipient of nextGroup) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,6 +38,7 @@ 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';
|
||||
@@ -150,10 +151,11 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
let recipientsToNotify = envelope.recipients;
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
// Get the currently active recipient.
|
||||
recipientsToNotify = envelope.recipients
|
||||
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
|
||||
.slice(0, 1);
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
|
||||
@@ -5,13 +5,14 @@ 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, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, 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';
|
||||
@@ -194,9 +195,6 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: true,
|
||||
team: {
|
||||
@@ -260,23 +258,9 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
},
|
||||
});
|
||||
|
||||
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 isRecipientsTurn =
|
||||
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
|
||||
isRecipientTurnBySigningOrder(envelope.recipients, recipient);
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
||||
|
||||
export type GetIsRecipientTurnOptions = {
|
||||
token: string;
|
||||
@@ -17,11 +19,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,24 +27,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
return true;
|
||||
}
|
||||
|
||||
const { recipients } = envelope;
|
||||
const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
|
||||
|
||||
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
|
||||
|
||||
if (currentRecipientIndex === -1) {
|
||||
if (!currentRecipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
||||
import { getDictatableNextRecipient } from '../../utils/recipient-groups';
|
||||
|
||||
export const getNextPendingRecipient = async ({
|
||||
documentId,
|
||||
@@ -16,33 +17,17 @@ 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 currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
|
||||
const nextRecipient = getDictatableNextRecipient({ recipients, currentRecipientId });
|
||||
|
||||
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
|
||||
if (!nextRecipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...recipients[currentIndex + 1],
|
||||
...nextRecipient,
|
||||
token: '',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,9 +23,13 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis
|
||||
let recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId: assistant.envelopeId,
|
||||
signingOrder: {
|
||||
gte: assistant.signingOrder ?? 0,
|
||||
},
|
||||
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 } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
|
||||
@@ -40,6 +40,7 @@ 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';
|
||||
@@ -694,7 +695,10 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
const nextRecipient = pendingRecipients[0];
|
||||
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
|
||||
|
||||
// Dictation only applies when the next step is a single recipient.
|
||||
const nextRecipient = nextGroup.length === 1 ? nextGroup[0] : null;
|
||||
|
||||
if (nextRecipient) {
|
||||
auditLogsToCreate.push(
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
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);
|
||||
};
|
||||
@@ -51,6 +51,24 @@ 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([
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, 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';
|
||||
@@ -22,11 +23,32 @@ export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
||||
return recipient.role === RecipientRole.CC;
|
||||
};
|
||||
|
||||
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
|
||||
/**
|
||||
* 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 }>,
|
||||
) => {
|
||||
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
||||
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
||||
|
||||
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
||||
@@ -120,6 +142,32 @@ 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.
|
||||
*
|
||||
|
||||
@@ -44,10 +44,13 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user