mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 23:32:29 +10:00
feat: add signing group editor operations
This commit is contained in:
@@ -1,7 +1,15 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from './recipient-groups';
|
import {
|
||||||
|
extractRecipientToNewStep,
|
||||||
|
groupRecipientsBySigningOrder,
|
||||||
|
mergeSteps,
|
||||||
|
moveRecipientToStep,
|
||||||
|
normalizeGroupedSigningOrders,
|
||||||
|
reorderStep,
|
||||||
|
ungroupStep,
|
||||||
|
} from './recipient-groups';
|
||||||
|
|
||||||
describe('groupRecipientsBySigningOrder', () => {
|
describe('groupRecipientsBySigningOrder', () => {
|
||||||
it('groups non-CC recipients sharing a signing order into steps', () => {
|
it('groups non-CC recipients sharing a signing order into steps', () => {
|
||||||
@@ -131,3 +139,191 @@ describe('normalizeGroupedSigningOrders', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -107,3 +107,187 @@ export const normalizeGroupedSigningOrders = <T extends GroupableRecipient>(
|
|||||||
...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })),
|
...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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetStep.members.some((member) => member.formId === formId)) {
|
||||||
|
return recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user