mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 00:34:35 +10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976ad7fcd5 | ||
|
|
f4a7b4db0d | ||
|
|
b152d11461 | ||
|
|
0557ce3dce | ||
|
|
6b8eb9fc6a | ||
|
|
12d44e1c59 | ||
|
|
9dc66afc7b | ||
|
|
7a13be6bf2 | ||
|
|
1032028395 | ||
|
|
2396d0d5d0 | ||
|
|
dac262edc9 | ||
|
|
25eb4ffedf | ||
|
|
ee0ea82635 | ||
|
|
5f7f6698fd | ||
|
|
402809c809 | ||
|
|
733e273c05 | ||
|
|
7c7933c2d4 | ||
|
|
bb120d65dd | ||
|
|
9c14aa6297 | ||
|
|
6387e809a8 | ||
|
|
5b2b1591a4 | ||
|
|
e5cb4c6bfd | ||
|
|
8185f916d3 | ||
|
|
c308dbf257 |
@@ -0,0 +1,222 @@
|
||||
---
|
||||
date: 2026-05-07
|
||||
title: Pdf Placeholder Selection Fields
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
|
||||
|
||||
```text
|
||||
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
|
||||
```
|
||||
|
||||
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the current placeholder grammar unchanged.
|
||||
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
|
||||
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
|
||||
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
|
||||
- Use `options` as the only public list key in PDF placeholders.
|
||||
- Convert `options` into internal `fieldMeta.values` during parsing.
|
||||
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semicolon placeholder syntax.
|
||||
- No `values` alias in PDF placeholder syntax.
|
||||
- No database migration.
|
||||
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
|
||||
|
||||
## Placeholder Syntax
|
||||
|
||||
Use the existing comma-separated placeholder format:
|
||||
|
||||
```text
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
|
||||
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
|
||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
|
||||
|
||||
Parsing rules:
|
||||
|
||||
- Split top-level placeholder tokens on unescaped commas.
|
||||
- Split metadata tokens on the first unescaped equals sign.
|
||||
- Split `options` on unescaped pipes.
|
||||
- Trim option values and drop empty values.
|
||||
- Preserve option order.
|
||||
- Support escaped delimiters: `\,`, `\=`, and `\|`.
|
||||
- Treat field type values case-insensitively.
|
||||
|
||||
## Field Type Mapping
|
||||
|
||||
- `checkbox` maps to `FieldType.CHECKBOX`.
|
||||
- `radio` maps to `FieldType.RADIO`.
|
||||
- `dropdown` maps to `FieldType.DROPDOWN`.
|
||||
|
||||
## Metadata Mapping
|
||||
|
||||
### Checkbox
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
fieldMeta: {
|
||||
type: 'checkbox',
|
||||
validationRule: 'Select at least',
|
||||
validationLength: 1,
|
||||
values: [
|
||||
{ id: 1, value: 'Email', checked: true },
|
||||
{ id: 2, value: 'SMS', checked: false },
|
||||
{ id: 3, value: 'Phone', checked: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `checked`
|
||||
- `direction=vertical|horizontal`
|
||||
- `validationRule=atLeast|exactly|atMost`
|
||||
- `validationLength=1`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
|
||||
|
||||
Checkbox placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
### Radio
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, value: 'Card', checked: false },
|
||||
{ id: 2, value: 'Bank transfer', checked: true },
|
||||
{ id: 3, value: 'Check', checked: false },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `selected`, `default`, or `defaultValue`
|
||||
- `direction=vertical|horizontal`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
Radio placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
### Dropdown
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
|
||||
defaultValue: 'Legal',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `selected`, `default`, or `defaultValue`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
`defaultValue` should only be set if it matches one parsed option.
|
||||
|
||||
Dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
## Code Touchpoints
|
||||
|
||||
- `packages/lib/server-only/pdf/helpers.ts`
|
||||
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
|
||||
- Add delimiter-aware helpers for commas, equals signs, and pipes.
|
||||
- `packages/lib/server-only/pdf/auto-place-fields.ts`
|
||||
- Replace plain comma splitting with delimiter-aware splitting.
|
||||
- Preserve the existing positional structure: field type, recipient, metadata.
|
||||
- `packages/lib/types/field-meta.ts`
|
||||
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
|
||||
- `packages/ui/primitives/document-flow/field-content.tsx`
|
||||
- Display a radio fallback when a placeholder-created radio has no options.
|
||||
- Docs:
|
||||
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
|
||||
- `apps/docs/content/docs/developers/api/fields.mdx`
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `options=Yes|No|Maybe` becomes stable radio values.
|
||||
- `selected=No` marks only the matching radio option checked.
|
||||
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
|
||||
- Dropdown `options` and `defaultValue` normalize correctly.
|
||||
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
|
||||
|
||||
E2E/API tests:
|
||||
|
||||
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
|
||||
- Verify created fields have schema-compatible metadata and expected options/defaults.
|
||||
|
||||
Suggested verification:
|
||||
|
||||
```bash
|
||||
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
|
||||
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
|
||||
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
|
||||
npx tsc --noEmit -p packages/lib/tsconfig.json
|
||||
npx tsc --noEmit -p apps/remix/tsconfig.json
|
||||
```
|
||||
|
||||
Do not use `npm run build` for routine verification unless explicitly requested.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the existing placeholder format.
|
||||
- Use only `options` publicly.
|
||||
- Keep `values` as an internal metadata field only.
|
||||
- Use `|` as the option delimiter inside `options`.
|
||||
@@ -2,7 +2,7 @@ name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v24.x
|
||||
default: v22.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
|
||||
To run Documenso locally, you will need
|
||||
|
||||
- Node.js (v24 or above)
|
||||
- Node.js (v22 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24 or later
|
||||
- npm 11.17 or later
|
||||
- Node.js 22 or later
|
||||
- npm 11 or later
|
||||
- PostgreSQL 14 or later
|
||||
- A Linux server (for systemd service setup)
|
||||
|
||||
|
||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
||||
|
||||
| Requirement | Version |
|
||||
| ----------- | ------- |
|
||||
| Node.js | 24+ |
|
||||
| npm | 11.17+ |
|
||||
| Node.js | 22+ |
|
||||
| npm | 11+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||
| SQLite | Not suitable for production workloads |
|
||||
| MongoDB | Relational database required |
|
||||
| Node.js < 24 | Modern JavaScript features required |
|
||||
| Node.js < 22 | Modern JavaScript features required |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
|
||||
| `maxValue` | Number | Maximum allowed value |
|
||||
| `numberFormat` | Format string | Number display format |
|
||||
|
||||
### Selection Field Options
|
||||
|
||||
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
|
||||
Separate choices with pipe (`|`) characters.
|
||||
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
| Option | Applies To | Values | Description |
|
||||
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
|
||||
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
|
||||
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
|
||||
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
|
||||
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
|
||||
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
|
||||
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
|
||||
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
|
||||
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
|
||||
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
|
||||
|
||||
For checkbox validation, `validationLength` defines the option count:
|
||||
- `atLeast` means at least that many options must be selected
|
||||
- `exactly` means exactly that many options must be selected
|
||||
- `atMost` means at most that many options must be selected
|
||||
|
||||
If an option needs a literal delimiter, escape it with a backslash:
|
||||
|
||||
```
|
||||
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
|
||||
```
|
||||
|
||||
### Examples with Options
|
||||
|
||||
```
|
||||
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
|
||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||
{{name, r1, fontSize=14}}
|
||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
|
||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM oven/bun:1 AS dependencies-env
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --production
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN bun run build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS dependencies-env
|
||||
RUN npm i -g pnpm
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --prod --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN pnpm build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -1,7 +1,4 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
@@ -20,9 +17,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -34,8 +29,6 @@ const ZBulkSendFormSchema = z.object({
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
@@ -49,9 +42,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
@@ -61,20 +51,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
|
||||
|
||||
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
const result = await uploadBulkSend({
|
||||
await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setValidationError(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setValidationError(null);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
|
||||
form.resetField('file');
|
||||
}}
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -257,67 +218,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationError !== null && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="max-h-32 overflow-y-auto">
|
||||
{match(validationError)
|
||||
.with({ type: 'PARSE_ERROR' }, () => (
|
||||
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
|
||||
))
|
||||
.with({ type: 'EMPTY' }, () => (
|
||||
<Trans>
|
||||
The CSV does not contain any rows. Please add at least one row of recipient details.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
|
||||
<Trans>
|
||||
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
|
||||
<>
|
||||
<Trans>
|
||||
The CSV is missing the following required columns. Please download the template CSV for the
|
||||
correct format.
|
||||
</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{missingColumns.map((column) => (
|
||||
<li key={column} className="font-mono">
|
||||
{column}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
|
||||
<>
|
||||
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{rowErrors.map((rowError, index) => (
|
||||
<li key={index}>
|
||||
<Trans>
|
||||
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
|
||||
email or empty
|
||||
</Trans>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
|
||||
code === AppErrorCode.LIMIT_EXCEEDED ? (
|
||||
<Trans>The CSV exceeds the maximum file size.</Trans>
|
||||
) : (
|
||||
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
@@ -340,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
type="button"
|
||||
>
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { EntryContext, RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { nonceContext } from './utils/nonce';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
@@ -20,7 +19,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: RouterContextProvider,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -34,7 +33,7 @@ export default async function handleRequest(
|
||||
// scripts it injects (route manifest, hydration data, module preloads).
|
||||
// The same nonce is also exposed to the React tree via the root loader so
|
||||
// our own inline scripts/styles can carry it.
|
||||
const nonce = loadContext.get(nonceContext) || undefined;
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { type MiddlewareFunction, redirect } from 'react-router';
|
||||
|
||||
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { MiddlewareFunction } from 'react-router';
|
||||
|
||||
import { getRequestNonce } from '../../server/load-context';
|
||||
import { nonceContext } from '../utils/nonce';
|
||||
|
||||
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
|
||||
context.set(nonceContext, getRequestNonce());
|
||||
};
|
||||
@@ -23,16 +23,13 @@ import {
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
import { nonceMiddleware } from '~/middleware/nonce';
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
import { nonce, nonceContext } from './utils/nonce';
|
||||
|
||||
export const middleware = [nonceMiddleware];
|
||||
import { nonce } from './utils/nonce';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -77,7 +74,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
nonce: context.get(nonceContext),
|
||||
nonce: context.nonce,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -20,18 +20,16 @@ import {
|
||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||
|
||||
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
|
||||
import { adminMiddleware } from '~/middleware/admin';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Admin`);
|
||||
}
|
||||
|
||||
export const middleware = [adminMiddleware];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
const { user } = await getSession(request);
|
||||
|
||||
const license = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
|
||||
export const loader = async () => {
|
||||
export const loader = () => {
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
return Response.json({
|
||||
isAvailable: certStatus.isAvailable,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const loader = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { createContext, useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce. Set by the root route middleware, read with
|
||||
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
|
||||
*/
|
||||
export const nonceContext = createContext<string>('');
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.18.0"
|
||||
"version": "2.17.0"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,4 @@ export default {
|
||||
// kept without a trailing slash so they match exactly, and so the bare
|
||||
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
|
||||
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||
future: {
|
||||
v8_middleware: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
@@ -24,11 +24,12 @@ export const appContext = async (c: Context, next: Next) => {
|
||||
|
||||
// These are non page paths like API.
|
||||
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
|
||||
return await next();
|
||||
return next();
|
||||
}
|
||||
|
||||
// Add context to any pages you want here.
|
||||
return await next();
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
const setAppContext = (c: Context, context: AppContext) => {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import { RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
import { CSP_NONCE_KEY } from './security-headers';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
|
||||
* `hono/context-storage` (enabled in `server/router.ts`).
|
||||
* Augment React Router's `AppLoadContext` so loaders, actions, and
|
||||
* `entry.server` can access fields by name without casts.
|
||||
*/
|
||||
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext {
|
||||
/**
|
||||
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
|
||||
* so it can be threaded into `<ServerRouter nonce>` and root loader
|
||||
* data, which then feeds `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
nonce: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
|
||||
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
|
||||
* production (`hono-react-router-adapter/node`).
|
||||
*
|
||||
* The Hono context isn't passed directly by the adapter, so we read it via
|
||||
* `hono/context-storage`, which is enabled in `server/router.ts`.
|
||||
*/
|
||||
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
|
||||
export const getLoadContext = (): AppLoadContext => {
|
||||
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
|
||||
return { nonce };
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { lingui } from '@lingui/vite-plugin';
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import serverAdapter from 'hono-react-router-adapter/vite';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig, normalizePath } from 'vite';
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
@@ -54,7 +53,7 @@ export default defineConfig({
|
||||
entry: 'server/router.ts',
|
||||
getLoadContext: async () => {
|
||||
const { getLoadContext } = await import('./server/load-context');
|
||||
return getLoadContext() as unknown as AppLoadContext;
|
||||
return getLoadContext();
|
||||
},
|
||||
exclude: [
|
||||
// Spread the defaults but replace the /.css$/ rule so that Bull
|
||||
|
||||
+12
-25
@@ -1,7 +1,7 @@
|
||||
###########################
|
||||
# BASE CONTAINER #
|
||||
###########################
|
||||
FROM node:24-alpine3.23 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
RUN apk add --no-cache font-freefont
|
||||
@@ -19,10 +19,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install the exact turbo version resolved in the lockfile, without installing
|
||||
# the rest of the dependency tree (prune must run before any npm ci).
|
||||
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
|
||||
&& npm install -g "turbo@${TURBO_VERSION}"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
# Outputs to the /out folder
|
||||
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||
@@ -42,9 +39,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
|
||||
WORKDIR /app
|
||||
|
||||
# Disable husky from installing hooks
|
||||
ENV HUSKY=0
|
||||
ENV DOCKER_OUTPUT=1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HUSKY 0
|
||||
ENV DOCKER_OUTPUT 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Encryption keys
|
||||
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
@@ -88,17 +85,17 @@ COPY --from=builder /app/out/full/ .
|
||||
# Finally copy the turbo.json file so that we can run turbo commands
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
RUN npx turbo run build --filter=@documenso/remix...
|
||||
RUN turbo run build --filter=@documenso/remix...
|
||||
|
||||
###########################
|
||||
# RUNNER CONTAINER #
|
||||
###########################
|
||||
FROM base AS runner
|
||||
|
||||
ENV HUSKY=0
|
||||
ENV DOCKER_OUTPUT=1
|
||||
ENV HUSKY 0
|
||||
ENV DOCKER_OUTPUT 1
|
||||
|
||||
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
|
||||
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
|
||||
@@ -121,16 +118,7 @@ COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config
|
||||
# Copy the patches across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||
|
||||
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
|
||||
# Strip build-time residue that ships as production dependencies but is never
|
||||
# executed at runtime.
|
||||
RUN rm -rf \
|
||||
node_modules/react-email/dist/cli \
|
||||
node_modules/esbuild \
|
||||
node_modules/@esbuild \
|
||||
node_modules/.bin/esbuild \
|
||||
node_modules/.bin/email
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nodejs.org/docs/advanced-features/output-file-tracing
|
||||
@@ -141,9 +129,8 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
|
||||
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
|
||||
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
|
||||
|
||||
# Generate the prisma client again, this time only targeting the client generator
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
|
||||
&& npm cache clean --force
|
||||
# Generate the prisma client again
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
|
||||
|
||||
|
||||
# Get the start script from docker/
|
||||
|
||||
Generated
+10
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -102,8 +102,8 @@
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
"npm": ">=11.17.0"
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=11.11.0"
|
||||
}
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -416,7 +416,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -3153,7 +3153,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
|
||||
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "10.5.0",
|
||||
@@ -3165,7 +3164,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
|
||||
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "10.5.0",
|
||||
@@ -3176,14 +3174,12 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
|
||||
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
|
||||
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@commitlint/cli": {
|
||||
@@ -5727,7 +5723,6 @@
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
|
||||
"integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "^10.5.0",
|
||||
@@ -10300,14 +10295,12 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.19.3.tgz",
|
||||
"integrity": "sha512-+D6v7RIF21bJrZAXiiIdW0qR73TleYVCqTDozokdEHdGeqN997O7jJW5z+43s5CVwWTZJIwFGpoeXxDDyXDVxA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/driver-adapter-utils": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-6.19.3.tgz",
|
||||
"integrity": "sha512-UUxn6VLfKKVqm5n9vexOQgFJ9TCBIxupb7F5FzLQ6iM2VV0WZxhgzbQVqBDsGY+VmQLmqaSoKRusEfy3O5KZpA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3"
|
||||
@@ -10356,14 +10349,12 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.19.3.tgz",
|
||||
"integrity": "sha512-rzHJaIZEnEDUWNjjFAimsyMCS9osPxwbwiAbymwFprSHJSAglMxMDVU+xbQUCLeDsjUF09W5ag/aBPL2t3YqgA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/generator-helper": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.19.3.tgz",
|
||||
"integrity": "sha512-13S8ngSWVKcyuRFqaK/JGMyovjQC5sOKJ9A+ufqePQWeIUbvKO2QY2CmhcV4JAa2vuN22//4Sip5mORVKwS0sw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
@@ -10384,7 +10375,6 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-6.19.3.tgz",
|
||||
"integrity": "sha512-1V5ba+BNtGFFlgoA8ecyAbjmoMWzIrEuebmLbJpeS7qbhnY6vbc1OZ6qc55lI/VAkfdALSL5vePG5KF9P8feLw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
@@ -10415,21 +10405,18 @@
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-/5JrsJQAOIWSl+9WMM/0ugd737kT9zdMNr7EFaP7ogeMxAhxQ78uaOV6JYGEocD8LC0gZx7MXmVE/CTfYdJ2iA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/schema-engine-wasm": {
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/schema-engine-wasm/-/schema-engine-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-mXtzeePkSdqpr/HAIclqzQEf+tBzLawy7NQ9AaRCfg8N/cruavpsocgbSwOOfV7JTk9JubyzKuR3tp2bNNuWbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/schema-files-loader": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-6.19.3.tgz",
|
||||
"integrity": "sha512-rnoL2PgopghakRZLCZwj+d7LPvjbY9mFholFWuibEKzwO7aqS16s60Hz4wxAnKwqOcS/M6pV7QLrsHBYnNR0KA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/prisma-schema-wasm": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
@@ -16287,7 +16274,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
|
||||
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "10.5.0",
|
||||
@@ -16639,7 +16625,6 @@
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/collapse-white-space": {
|
||||
@@ -19886,7 +19871,6 @@
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
||||
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -22553,7 +22537,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -26400,7 +26383,6 @@
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
|
||||
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/generator-helper": "^6.16.1",
|
||||
@@ -26426,7 +26408,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz",
|
||||
"integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mrleebo/prisma-ast": "^0.13.1",
|
||||
@@ -27627,7 +27608,6 @@
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
||||
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
@@ -31674,7 +31654,6 @@
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.3.5.tgz",
|
||||
"integrity": "sha512-PDuRRCdX1d6ch6UclNvrqM1SLb2qvuaQPvZT/OHRrcxOQqQUnCz009aBc1uxuwMho1+d42TTDCQr+HPXhTSJtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/dmmf": "^6.16.3",
|
||||
@@ -32431,17 +32410,17 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.76",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "5.6.2",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
},
|
||||
"packages/prisma/node_modules/@esbuild/aix-ppc64": {
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -41,10 +41,10 @@
|
||||
"translate:extract": "lingui extract --clean",
|
||||
"translate:compile": "lingui compile"
|
||||
},
|
||||
"packageManager": "npm@11.19.1",
|
||||
"packageManager": "npm@11.11.0",
|
||||
"engines": {
|
||||
"npm": ">=11.17.0",
|
||||
"node": ">=24.0.0"
|
||||
"npm": ">=11.11.0",
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
|
||||
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
|
||||
});
|
||||
|
||||
// They should NOT see the organisation settings heading for this org.
|
||||
await expect(page.getByText('Organization Settings')).not.toBeVisible();
|
||||
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Access control: UI ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Verify we're on the admin organisation page
|
||||
await expect(page.getByText(`Manage organization`)).toBeVisible();
|
||||
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
|
||||
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
|
||||
|
||||
// Check that the organisation members table shows the correct roles
|
||||
const ownerRow = page.getByRole('row', { name: ownerUser.email });
|
||||
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Should show 404 error
|
||||
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByLabel('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
|
||||
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
if (reason) {
|
||||
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel document' }).click();
|
||||
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await selectDocumentStatusFilter(page, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await selectDocumentStatusFilter(page, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
|
||||
|
||||
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
|
||||
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
|
||||
await page.waitForURL(`/settings/organisations`);
|
||||
await expectTextToBeVisible(page, 'No results found');
|
||||
await page.getByRole('button', { name: 'Create organization' }).click();
|
||||
await page.getByRole('button', { name: 'Create organisation' }).click();
|
||||
|
||||
await page.getByLabel('Organization Name*').fill('test');
|
||||
await page.getByLabel('Organisation Name*').fill('test');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
|
||||
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
|
||||
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
||||
const updatedOrganisationId = `organisation-${Date.now()}`;
|
||||
|
||||
// Update team.
|
||||
await page.getByLabel('Organization Name*').click();
|
||||
await page.getByLabel('Organization Name*').clear();
|
||||
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organization URL*').click();
|
||||
await page.getByLabel('Organization URL*').clear();
|
||||
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organisation Name*').click();
|
||||
await page.getByLabel('Organisation Name*').clear();
|
||||
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organisation URL*').click();
|
||||
await page.getByLabel('Organisation URL*').clear();
|
||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
|
||||
await page.getByRole('button', { name: 'Create group' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member1' }).click();
|
||||
await page.getByRole('option', { name: 'Member2' }).click();
|
||||
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm org roles have been applied to these members.
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
|
||||
|
||||
// Test updating the group.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
await page.getByRole('link', { name: 'Manage' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Member' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Member' }).click();
|
||||
// Remove Member3 by clicking the X on its chip in the multiselect.
|
||||
await page
|
||||
.getByTestId('group-members-picker')
|
||||
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm admins still get admin roles.
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
|
||||
|
||||
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
await page.getByRole('button', { name: 'Create group' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member4' }).click();
|
||||
await page.getByRole('option', { name: 'Member5' }).click();
|
||||
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
|
||||
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
|
||||
await expect(page.getByText('No results found').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
|
||||
|
||||
type SeedQuotaStateOptions = {
|
||||
organisationId: string;
|
||||
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
|
||||
});
|
||||
|
||||
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
|
||||
await expect(page.getByLabel('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
|
||||
|
||||
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Inherit from organization' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('inheritable-email-document-settings')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Inherit from organization' })
|
||||
.filter({ hasText: 'Inherit from organisation' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Override organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organization' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
|
||||
|
||||
// Wait for the organisation page to actually render — asserting straight after
|
||||
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
|
||||
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
|
||||
|
||||
// Selecting the inherit option stages the field back to inherited.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: /inherit from organization/i }).click();
|
||||
await page.getByRole('option', { name: /inherit from organisation/i }).click();
|
||||
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
|
||||
// An empty group would just look broken, so it explains itself directly under the switcher.
|
||||
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText(/permission to manage this organization/i);
|
||||
await expect(emptyState).toContainText(/permission to manage this organisation/i);
|
||||
|
||||
// Team and account pages remain navigable.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
|
||||
|
||||
@@ -93,8 +93,6 @@ export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYW
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TRANSPORT = () => env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
|
||||
|
||||
/**
|
||||
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
*
|
||||
|
||||
@@ -2,10 +2,12 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { createElement } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
@@ -15,6 +17,14 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
@@ -31,20 +41,24 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const { recipients } = template;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
// The CSV is validated upfront when the bulk send is uploaded, this acts as
|
||||
// a final safeguard prior to processing.
|
||||
const csvValidationResult = validateBulkSendCsv({
|
||||
csvContent,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
if (!csvValidationResult.success) {
|
||||
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
}
|
||||
|
||||
const rows = csvValidationResult.data;
|
||||
const { recipients } = template;
|
||||
|
||||
// Validate CSV structure
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -65,6 +79,22 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
// Process each row
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
try {
|
||||
for (const [recipientIndex] of recipients.entries()) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
id: {
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { createLocalSigner } from '@documenso/signing/transports/local';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { NEXT_PRIVATE_SIGNING_TRANSPORT } from '../../constants/app';
|
||||
|
||||
/**
|
||||
* Whether the local P12 opens with the configured passphrase and is in date.
|
||||
* Skips AIA so this stays offline. gcloud-hsm and csc always report available.
|
||||
*/
|
||||
export const getCertificateStatus = async () => {
|
||||
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
|
||||
|
||||
// Cannot inspect a remote HSM or CSC provider from this process.
|
||||
if (transport === 'gcloud-hsm' || transport === 'csc') {
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
// Anything else (typo, leftover `http`) would throw at seal time.
|
||||
if (transport !== 'local') {
|
||||
return { isAvailable: false };
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
const signer = await createLocalSigner({ buildChain: false });
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const certificate = new X509Certificate(Buffer.from(signer.certificate));
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const isWithinValidityPeriod = new Date(certificate.validFrom) <= now && now <= new Date(certificate.validTo);
|
||||
|
||||
return { isAvailable: isWithinValidityPeriod };
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
envelopeId: envelope.id,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const { documentData } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
|
||||
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
envelopeId: envelope.id,
|
||||
fileName: data.file.name,
|
||||
});
|
||||
|
||||
// Upload the new PDF and get a new DocumentData record.
|
||||
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { PDF, rgb } from '@libpdf/core';
|
||||
import type { FieldType, Recipient } from '@prisma/client';
|
||||
|
||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||
import {
|
||||
parseFieldMetaFromPlaceholder,
|
||||
parseFieldTypeFromPlaceholder,
|
||||
parsePlaceholderData,
|
||||
parseRawFieldMetaFromPlaceholder,
|
||||
} from './helpers';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||
type ExtractPlaceholdersLogContext = {
|
||||
envelopeId?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (
|
||||
pdf: Buffer,
|
||||
logContext?: ExtractPlaceholdersLogContext,
|
||||
): Promise<PlaceholderInfo[]> => {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
|
||||
const placeholders: PlaceholderInfo[] = [];
|
||||
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
|
||||
const placeholderData = parsePlaceholderData(innerMatch[1]);
|
||||
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
||||
|
||||
let fieldType: FieldType;
|
||||
@@ -109,14 +124,51 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
|
||||
const recipient = recipientOrMeta;
|
||||
|
||||
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
|
||||
/*
|
||||
Parse and validate the field metadata. A malformed selection placeholder
|
||||
(e.g. an unknown validation rule or a default value that doesn't match an
|
||||
option) is skipped like an invalid field type rather than aborting the whole
|
||||
upload, which may contain other valid placeholders and files.
|
||||
*/
|
||||
let fieldAndMeta: TFieldAndMeta;
|
||||
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
try {
|
||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
|
||||
/*
|
||||
Surface schema failures as INVALID_BODY (400) instead of letting the raw
|
||||
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
|
||||
*/
|
||||
if (!parsedFieldAndMeta.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
fieldAndMeta = parsedFieldAndMeta.data;
|
||||
} catch (error) {
|
||||
const appError = AppError.parseError(error);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
envelopeId: logContext?.envelopeId,
|
||||
fileName: logContext?.fileName,
|
||||
placeholder,
|
||||
page: page.index + 1,
|
||||
code: appError.code,
|
||||
message: appError.message,
|
||||
},
|
||||
'Skipping placeholder with invalid field metadata',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
LibPDF returns bbox in points with bottom-left origin.
|
||||
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
|
||||
*/
|
||||
export const extractPdfPlaceholders = async (
|
||||
pdf: Buffer,
|
||||
logContext?: ExtractPlaceholdersLogContext,
|
||||
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
|
||||
|
||||
if (placeholders.length === 0) {
|
||||
return { cleanedPdf: pdf, placeholders: [] };
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
parseFieldMetaFromPlaceholder,
|
||||
parseFieldTypeFromPlaceholder,
|
||||
parsePlaceholderData,
|
||||
parseRawFieldMetaFromPlaceholder,
|
||||
} from './helpers';
|
||||
|
||||
const expectInvalidBody = (fn: () => unknown) => {
|
||||
try {
|
||||
fn();
|
||||
expect.unreachable('Expected an AppError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseFieldTypeFromPlaceholder function', () => {
|
||||
it('maps known field type strings to the FieldType enum', () => {
|
||||
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
|
||||
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
|
||||
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
|
||||
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims surrounding whitespace', () => {
|
||||
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
|
||||
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
|
||||
});
|
||||
|
||||
it('throws INVALID_BODY for an unknown field type', () => {
|
||||
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlaceholderData function', () => {
|
||||
it('splits top-level parts on commas and trims each token', () => {
|
||||
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
|
||||
});
|
||||
|
||||
it('does not split on escaped commas', () => {
|
||||
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
|
||||
'dropdown',
|
||||
'r1',
|
||||
'options=Legal\\, Compliance|Sales',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRawFieldMetaFromPlaceholder function', () => {
|
||||
it('splits each token into a key/value entry', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
|
||||
required: 'true',
|
||||
fontSize: '12',
|
||||
});
|
||||
});
|
||||
|
||||
it('only splits on the first unescaped equals sign', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
|
||||
});
|
||||
|
||||
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
|
||||
fontSize: '14',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFieldMetaFromPlaceholder function', () => {
|
||||
describe('non-field-meta cases', () => {
|
||||
it('returns undefined for signature and free signature fields', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
|
||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when there is no metadata', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic metadata', () => {
|
||||
it('coerces required/readOnly to booleans (case-insensitive)', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
required: true,
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces numeric properties to numbers', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
fontSize: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops numeric properties that are not a number', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
|
||||
|
||||
expect(parsed).toEqual({ type: 'text' });
|
||||
expect(parsed).not.toHaveProperty('fontSize');
|
||||
});
|
||||
|
||||
it('keeps label/placeholder for non-selection fields', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
label: 'Company Name',
|
||||
placeholder: 'Acme',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('radio fields', () => {
|
||||
it('builds stable values from options', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Yes' },
|
||||
{ id: 2, checked: false, value: 'No' },
|
||||
{ id: 3, checked: false, value: 'Maybe' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the selected option as checked', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Yes' },
|
||||
{ id: 2, checked: true, value: 'No' },
|
||||
{ id: 3, checked: false, value: 'Maybe' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when a default value is provided without options', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
|
||||
});
|
||||
|
||||
it('throws when the default value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
|
||||
});
|
||||
|
||||
it('throws when options is empty', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkbox fields', () => {
|
||||
it('builds values with checked state, validation rule alias and length', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder(
|
||||
{
|
||||
options: 'Email|SMS|Phone',
|
||||
checked: 'Email|Phone',
|
||||
validationRule: 'atLeast',
|
||||
validationLength: '1',
|
||||
},
|
||||
FieldType.CHECKBOX,
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'checkbox',
|
||||
validationRule: 'Select at least',
|
||||
validationLength: 1,
|
||||
values: [
|
||||
{ id: 1, checked: true, value: 'Email' },
|
||||
{ id: 2, checked: false, value: 'SMS' },
|
||||
{ id: 3, checked: true, value: 'Phone' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an unknown validation rule', () => {
|
||||
expectInvalidBody(() =>
|
||||
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when checked values are provided without options', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
|
||||
});
|
||||
|
||||
it('throws when a checked value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropdown fields', () => {
|
||||
it('builds values and sets a matching default value', () => {
|
||||
expect(
|
||||
parseFieldMetaFromPlaceholder(
|
||||
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
|
||||
FieldType.DROPDOWN,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
|
||||
defaultValue: 'Canada',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the default value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection field options parsing', () => {
|
||||
it('trims option values and drops empty entries', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'A' }, { value: 'B' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses escaped delimiters through the full placeholder pipeline', () => {
|
||||
const [, , ...fieldMetaData] = parsePlaceholderData(
|
||||
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
|
||||
);
|
||||
|
||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
|
||||
recipientIndex: number;
|
||||
};
|
||||
|
||||
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
|
||||
atLeast: 'Select at least',
|
||||
exactly: 'Select exactly',
|
||||
atMost: 'Select at most',
|
||||
};
|
||||
|
||||
/*
|
||||
Split a string on a delimiter, treating `\` as an escape for the next character.
|
||||
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
|
||||
|
||||
With delimiter ',' (top-level placeholder parts):
|
||||
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
|
||||
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
|
||||
|
||||
With delimiter '=' (split one field metadata token into key + value):
|
||||
'options=Card/Check|Bank Transfer'
|
||||
-> ['options', 'Card/Check|Bank Transfer']
|
||||
|
||||
With delimiter '|' (split option list inside 'options='):
|
||||
'Card/Check|Bank Transfer'
|
||||
-> ['Card/Check', 'Bank Transfer']
|
||||
*/
|
||||
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
|
||||
const parts: string[] = [];
|
||||
let currentPart = '';
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const char = value[index];
|
||||
const nextChar = value[index + 1];
|
||||
|
||||
if (char === '\\' && nextChar) {
|
||||
currentPart += char + nextChar;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === delimiter) {
|
||||
parts.push(currentPart);
|
||||
currentPart = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentPart += char;
|
||||
}
|
||||
|
||||
parts.push(currentPart);
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
/*
|
||||
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
|
||||
|
||||
E.g.
|
||||
'Legal\, Compliance' -> 'Legal, Compliance'
|
||||
'Card\|Check' -> 'Card|Check'
|
||||
'A\=B' -> 'A=B'
|
||||
'C\D' -> 'C\D'
|
||||
*/
|
||||
const unescapePlaceholderValue = (value: string): string => {
|
||||
return value.replace(/\\([,=|\\])/g, '$1');
|
||||
};
|
||||
|
||||
/*
|
||||
Cleans up a selection option/default after splitting:
|
||||
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
|
||||
|
||||
E.g.
|
||||
' Legal\, Compliance ' -> 'Legal, Compliance'
|
||||
*/
|
||||
const normalizePlaceholderSelectionValue = (value: string): string => {
|
||||
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
/*
|
||||
Split an options string into individual choices.
|
||||
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
|
||||
|
||||
E.g.
|
||||
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
|
||||
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
|
||||
*/
|
||||
const parsePlaceholderOptions = (value: string): string[] => {
|
||||
return splitPlaceholderToken(value, '|')
|
||||
.map((option) => normalizePlaceholderSelectionValue(option))
|
||||
.filter((option) => option.length > 0);
|
||||
};
|
||||
|
||||
/*
|
||||
Split a placeholder string into top-level parts (field type, recipient, metadata).
|
||||
Splits on unescaped commas, then trims whitespace.
|
||||
|
||||
E.g.
|
||||
'SIGNATURE, r1, required=true'
|
||||
-> ['SIGNATURE', 'r1', 'required=true']
|
||||
*/
|
||||
export const parsePlaceholderData = (value: string): string[] => {
|
||||
return splitPlaceholderToken(value, ',').map((token) => token.trim());
|
||||
};
|
||||
|
||||
/*
|
||||
Transforms the field metadata string array into a record of key/value pairs.
|
||||
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
|
||||
|
||||
E.g.
|
||||
['required=true', 'fontSize=12', 'label=a=b']
|
||||
-> { required: 'true', fontSize: '12', label: 'a=b' }
|
||||
*/
|
||||
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
|
||||
const rawFieldMeta: Record<string, string> = {};
|
||||
|
||||
for (const fieldMeta of fieldMetaData) {
|
||||
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
|
||||
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
|
||||
|
||||
if (!rawKey || valueParts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = rawKey.trim();
|
||||
const value = valueParts.join('=').trim();
|
||||
|
||||
rawFieldMeta[key] = value;
|
||||
}
|
||||
|
||||
return rawFieldMeta;
|
||||
};
|
||||
|
||||
/*
|
||||
Parse field type string to FieldType enum.
|
||||
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
||||
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
|
||||
});
|
||||
};
|
||||
|
||||
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
|
||||
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
|
||||
|
||||
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
|
||||
};
|
||||
|
||||
const parseCheckboxValidationRule = (value: string): string => {
|
||||
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
|
||||
|
||||
if (!validationRule) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid checkbox placeholder validation rule: ${value}`,
|
||||
});
|
||||
}
|
||||
|
||||
return validationRule;
|
||||
};
|
||||
|
||||
const parseSelectionFieldOptions = (
|
||||
rawFieldMeta: Record<string, string>,
|
||||
fieldType: FieldType,
|
||||
): string[] | undefined => {
|
||||
const rawOptions = rawFieldMeta.options;
|
||||
|
||||
if (rawOptions === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOptions = parsePlaceholderOptions(rawOptions);
|
||||
|
||||
if (parsedOptions.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `${fieldType} placeholder options must contain at least one value`,
|
||||
});
|
||||
}
|
||||
|
||||
return parsedOptions;
|
||||
};
|
||||
|
||||
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
|
||||
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
||||
|
||||
if (!options && defaultValue) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Radio placeholder default value requires options',
|
||||
});
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
|
||||
|
||||
if (defaultValue && selectedOptionIndex === -1) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
|
||||
});
|
||||
}
|
||||
|
||||
parsedFieldMeta.values = options.map((option, index) => ({
|
||||
id: index + 1,
|
||||
checked: index === selectedOptionIndex,
|
||||
value: option,
|
||||
}));
|
||||
};
|
||||
|
||||
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
|
||||
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
|
||||
|
||||
if (!options && checkedValues.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Checkbox placeholder checked values require options',
|
||||
});
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
|
||||
|
||||
if (unmatchedCheckedValues.length > 0) {
|
||||
const unmatchedCheckedValue = unmatchedCheckedValues[0];
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
|
||||
' ',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
parsedFieldMeta.values = options.map((option, index) => ({
|
||||
id: index + 1,
|
||||
checked: checkedValues.includes(option),
|
||||
value: option,
|
||||
}));
|
||||
};
|
||||
|
||||
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
|
||||
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
|
||||
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
||||
|
||||
if (!options && defaultValue) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Dropdown placeholder default value requires options',
|
||||
});
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultValue && !options.includes(defaultValue)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
|
||||
});
|
||||
}
|
||||
|
||||
parsedFieldMeta.values = options.map((option) => ({
|
||||
value: option,
|
||||
}));
|
||||
|
||||
if (defaultValue) {
|
||||
parsedFieldMeta.defaultValue = defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Generic field metadata properties are simple properties consisting of a key and a value.
|
||||
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
|
||||
They don't require special handling.
|
||||
|
||||
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
|
||||
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
|
||||
They require special handling.
|
||||
*/
|
||||
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
|
||||
if (property === 'options' || property === 'default' || property === 'selected') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isSelectionField =
|
||||
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
|
||||
|
||||
if (!isSelectionField) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
property === 'label' ||
|
||||
property === 'placeholder' ||
|
||||
property === 'defaultValue' ||
|
||||
(fieldType === FieldType.CHECKBOX && property === 'checked')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/*
|
||||
Transform raw field metadata from placeholder format to schema format.
|
||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
|
||||
|
||||
const fieldTypeString = String(fieldType).toLowerCase();
|
||||
|
||||
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
||||
const parsedFieldMeta: Record<string, unknown> = {
|
||||
type: fieldTypeString,
|
||||
};
|
||||
|
||||
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
|
||||
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
||||
|
||||
for (const [property, value] of rawFieldMetaEntries) {
|
||||
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unescapedValue = unescapePlaceholderValue(value);
|
||||
|
||||
if (property === 'readOnly' || property === 'required') {
|
||||
parsedFieldMeta[property] = value === 'true';
|
||||
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
|
||||
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
|
||||
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
|
||||
} else if (
|
||||
property === 'fontSize' ||
|
||||
property === 'maxValue' ||
|
||||
property === 'minValue' ||
|
||||
property === 'characterLimit'
|
||||
property === 'characterLimit' ||
|
||||
property === 'validationLength'
|
||||
) {
|
||||
const numValue = Number(value);
|
||||
const numValue = Number(unescapedValue);
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
parsedFieldMeta[property] = numValue;
|
||||
}
|
||||
} else {
|
||||
parsedFieldMeta[property] = value;
|
||||
parsedFieldMeta[property] = unescapedValue;
|
||||
}
|
||||
}
|
||||
|
||||
match(fieldType)
|
||||
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.otherwise(() => undefined);
|
||||
|
||||
return parsedFieldMeta;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
|
||||
import { validateBulkSendCsv } from './validate-bulk-send-csv';
|
||||
|
||||
const buildCsv = (headers: string[], rows: string[][]) =>
|
||||
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
|
||||
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
|
||||
if (result.success) {
|
||||
throw new Error('Expected validation to fail, but it passed');
|
||||
}
|
||||
|
||||
return result.error;
|
||||
};
|
||||
|
||||
describe('validateBulkSendCsv', () => {
|
||||
describe('valid CSVs', () => {
|
||||
it('returns the parsed rows for a valid CSV', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[
|
||||
['alice@example.com', 'Alice'],
|
||||
['bob@example.com', 'Bob'],
|
||||
],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: [
|
||||
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
|
||||
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an empty string email so template defaults can be used', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the optional name column to be omitted entirely', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unknown extra columns', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates columns for every configured recipient', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[['alice@example.com', 'bob@example.com']],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows exactly the maximum number of rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PARSE_ERROR', () => {
|
||||
it('rejects a CSV that cannot be parsed', () => {
|
||||
const csvContent = 'recipient_1_email\n"unclosed quote';
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
|
||||
it('rejects a CSV with inconsistent column counts', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMPTY', () => {
|
||||
it('rejects an empty file', () => {
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
|
||||
it('rejects a CSV containing only a header row', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW_LIMIT_EXCEEDED', () => {
|
||||
it('rejects a CSV exceeding the default limit of 100 rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
|
||||
});
|
||||
|
||||
it('respects a custom maxRows option', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MISSING_COLUMNS', () => {
|
||||
it('rejects a CSV missing a required email column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
|
||||
});
|
||||
|
||||
it('reports every missing column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
|
||||
|
||||
expect(error).toEqual({
|
||||
type: 'MISSING_COLUMNS',
|
||||
missingColumns: ['recipient_2_email', 'recipient_3_email'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('INVALID_RECIPIENTS', () => {
|
||||
it('rejects a CSV containing an invalid email', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('references the offending row and column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates errors across multiple rows and recipients', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[
|
||||
['not-an-email', 'bob@example.com'],
|
||||
['alice@example.com', 'also-not-an-email'],
|
||||
],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [
|
||||
{ row: 1, column: 'recipient_1_email' },
|
||||
{ row: 2, column: 'recipient_2_email' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '../../utils/zod';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type TBulkSendCsvRow = Record<string, string | undefined>;
|
||||
|
||||
export type TBulkSendCsvRowError = {
|
||||
/**
|
||||
* The 1-indexed row number the error occurred on, excluding the header row.
|
||||
*/
|
||||
row: number;
|
||||
|
||||
/**
|
||||
* The column the error occurred in, such as `recipient_1_email`.
|
||||
*/
|
||||
column: string;
|
||||
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TBulkSendCsvError =
|
||||
| { type: 'PARSE_ERROR' }
|
||||
| { type: 'EMPTY' }
|
||||
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
|
||||
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
|
||||
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
|
||||
|
||||
export type TValidateBulkSendCsvResult =
|
||||
| { success: true; data: TBulkSendCsvRow[] }
|
||||
| { success: false; error: TBulkSendCsvError };
|
||||
|
||||
export type ValidateBulkSendCsvOptions = {
|
||||
csvContent: string;
|
||||
|
||||
/**
|
||||
* The number of recipients configured on the template, used to derive the
|
||||
* required `recipient_N_email` columns.
|
||||
*/
|
||||
recipientCount: number;
|
||||
|
||||
maxRows?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the CSV provided for a template bulk send.
|
||||
*
|
||||
* Returns a discriminated union so callers can surface structured error
|
||||
* details, such as which rows contain invalid recipients.
|
||||
*/
|
||||
export const validateBulkSendCsv = ({
|
||||
csvContent,
|
||||
recipientCount,
|
||||
maxRows = 100,
|
||||
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
|
||||
let rows: TBulkSendCsvRow[];
|
||||
|
||||
try {
|
||||
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
} catch {
|
||||
return { success: false, error: { type: 'PARSE_ERROR' } };
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: false, error: { type: 'EMPTY' } };
|
||||
}
|
||||
|
||||
if (rows.length > maxRows) {
|
||||
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
|
||||
}
|
||||
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
|
||||
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
|
||||
}
|
||||
|
||||
const rowErrors: TBulkSendCsvRowError[] = [];
|
||||
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
rowErrors.push({
|
||||
row: rowIndex + 1,
|
||||
column: emailKey,
|
||||
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
|
||||
}
|
||||
|
||||
return { success: true, data: rows };
|
||||
};
|
||||
@@ -35,6 +35,33 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: (RR7) Migrate to use this after RR7.
|
||||
// Note: If we actually ever proceed with this, there are multiple
|
||||
// locations where we will need to update this.
|
||||
// const user = await prisma.$transaction(async (tx) => {
|
||||
// const user = await tx.user.create({
|
||||
// data: {
|
||||
// name,
|
||||
// email: email.toLowerCase(),
|
||||
// password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
// signature,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await tx.account.create({
|
||||
// data: {
|
||||
// userId: user.id,
|
||||
// type: 'emailPassword', // Todo: (RR7)
|
||||
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
|
||||
// providerAccountId: user.id.toString(),
|
||||
// password: hashedPassword,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return user;
|
||||
// });
|
||||
|
||||
// Not used at the moment, uncomment if required.
|
||||
await onCreateUserHook(user).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
|
||||
@@ -81,20 +81,13 @@ describe('isPrivateUrl', () => {
|
||||
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect private IPv4-mapped IPv6 addresses (URL parser normalizes to hex)', () => {
|
||||
// new URL() normalizes "::ffff:127.0.0.1" to the hex form "::ffff:7f00:1",
|
||||
// so the embedded IPv4 must be decoded and re-checked. Otherwise a literal
|
||||
// host such as http://[::ffff:127.0.0.1] bypasses every dotted-decimal
|
||||
// check above (SSRF, see #2901).
|
||||
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(true);
|
||||
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(true);
|
||||
expect(isPrivateUrl('http://[::ffff:192.168.0.1]')).toBe(true);
|
||||
expect(isPrivateUrl('http://[::ffff:169.254.169.254]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should still allow public IPv4-mapped IPv6 addresses', () => {
|
||||
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
|
||||
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
|
||||
// of the checks handle. This is fine because dns.lookup never returns
|
||||
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
|
||||
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:1.1.1.1]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,25 +69,11 @@ export const isPrivateUrl = (url: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1)
|
||||
const v4MappedDotted = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
|
||||
if (v4MappedDotted) {
|
||||
return isPrivateUrl(`http://${v4MappedDotted[1]}`);
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6, hex form (e.g. ::ffff:7f00:1). `new URL()` normalizes the
|
||||
// dotted form above to this, so it must be decoded to the embedded IPv4 as
|
||||
// well - otherwise a literal host such as `http://[::ffff:127.0.0.1]` slips
|
||||
// through every dotted-decimal check above (SSRF, see #2901).
|
||||
const v4MappedHex = normalizedHost.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
|
||||
if (v4MappedHex) {
|
||||
const high = parseInt(v4MappedHex[1], 16);
|
||||
const low = parseInt(v4MappedHex[2], 16);
|
||||
const ipv4 = [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
|
||||
|
||||
return isPrivateUrl(`http://${ipv4}`);
|
||||
if (v4Mapped) {
|
||||
return isPrivateUrl(`http://${v4Mapped[1]}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+265
-265
File diff suppressed because it is too large
Load Diff
@@ -26,16 +26,16 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.76",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "5.6.2",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT,
|
||||
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER,
|
||||
NEXT_PUBLIC_SIGNING_CONTACT_INFO,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
} from '@documenso/lib/constants/app';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import type { PDF, Signer } from '@libpdf/core';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -22,7 +22,7 @@ const getSigner = async () => {
|
||||
return signer;
|
||||
}
|
||||
|
||||
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
|
||||
const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
signer = await match(transport)
|
||||
@@ -49,11 +49,6 @@ export const signPdf = async ({ pdf }: SignOptions) => {
|
||||
timestampAuthority: tsa ?? undefined,
|
||||
longTermValidation: !!tsa,
|
||||
archivalTimestamp: !!tsa,
|
||||
// A B-LTA signature (signer chain + RFC 3161 timestamp token + LTV
|
||||
// revocation data) can exceed the 12288-byte default placeholder,
|
||||
// depending on the signing certificate chain and the TSA responder.
|
||||
// The unused portion is zero-padding, so over-reserving is cheap.
|
||||
estimatedSize: tsa ? 32768 : undefined,
|
||||
});
|
||||
|
||||
return bytes;
|
||||
|
||||
@@ -22,20 +22,10 @@ const loadP12 = (): Uint8Array => {
|
||||
throw new Error('No certificate found for local signing');
|
||||
};
|
||||
|
||||
export type CreateLocalSignerOptions = {
|
||||
/**
|
||||
* Fetch missing intermediates via AIA. Leave on for sealing.
|
||||
* Turn off for health checks so they do not hit the network.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
buildChain?: boolean;
|
||||
};
|
||||
|
||||
export const createLocalSigner = async ({ buildChain = true }: CreateLocalSignerOptions = {}) => {
|
||||
export const createLocalSigner = async () => {
|
||||
const p12 = loadP12();
|
||||
|
||||
return await P12Signer.create(p12, env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || '', {
|
||||
buildChain,
|
||||
buildChain: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,7 +125,9 @@ export const createEnvelopeRouteCaller = async ({
|
||||
});
|
||||
|
||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const { documentData } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
|
||||
@@ -22,7 +22,6 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template
|
||||
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
@@ -880,15 +879,6 @@ export const templateRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const csvValidationResult = validateBulkSendCsv({
|
||||
csvContent: csv,
|
||||
recipientCount: template.recipients.length,
|
||||
});
|
||||
|
||||
if (!csvValidationResult.success) {
|
||||
return { success: false as const, error: csvValidationResult.error };
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.bulk-send-template',
|
||||
payload: {
|
||||
@@ -901,6 +891,6 @@ export const templateRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true as const };
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export type Dimensions = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type AutoSizedTextProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
maxHeight?: number;
|
||||
useRem?: boolean;
|
||||
};
|
||||
|
||||
const ITERATION_LIMIT = 20;
|
||||
const MAXIMUM_DIFFERENCE = 1; // px
|
||||
|
||||
function getElementDimensions(element: HTMLElement): Dimensions {
|
||||
const bbox = element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
width: bbox.width,
|
||||
height: bbox.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getBaseFontSize(): number {
|
||||
try {
|
||||
const fontSize = getComputedStyle(document.documentElement).fontSize;
|
||||
const parsed = parseFloat(fontSize);
|
||||
|
||||
// Check if we got a valid number
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
// Fallback to browser default if anything goes wrong
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
|
||||
function pxToRem(px: number): number {
|
||||
return px / getBaseFontSize();
|
||||
}
|
||||
|
||||
export function AutoSizedText({ children, className, maxHeight, useRem = false }: AutoSizedTextProps) {
|
||||
const childRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fontSize = useRef<number>(0);
|
||||
const fontSizeLowerBound = useRef<number>(0);
|
||||
const fontSizeUpperBound = useRef<number>(0);
|
||||
|
||||
const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => {
|
||||
const childElement = childRef.current;
|
||||
|
||||
if (!childElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newFontSize: number;
|
||||
|
||||
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
|
||||
|
||||
const isElementTooBig = childDimensions.width > parentDimensions.width || childDimensions.height > targetHeight;
|
||||
|
||||
if (isElementTooBig) {
|
||||
// Scale down if element is bigger than target
|
||||
newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2;
|
||||
fontSizeUpperBound.current = fontSize.current;
|
||||
} else if (childDimensions.width < parentDimensions.width || childDimensions.height < parentDimensions.height) {
|
||||
// Scale up if element is smaller than target
|
||||
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
|
||||
fontSizeLowerBound.current = fontSize.current;
|
||||
}
|
||||
|
||||
fontSize.current = newFontSize;
|
||||
|
||||
// Convert to rem if useRem is true
|
||||
const displayFontSize = useRem ? `${pxToRem(newFontSize)}rem` : `${newFontSize}px`;
|
||||
childElement.style.fontSize = displayFontSize;
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const childElement = childRef.current;
|
||||
const parentElement = childRef.current?.parentElement;
|
||||
|
||||
if (!childElement || !parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDimensions = entry.contentRect;
|
||||
|
||||
// Reset iteration parameters
|
||||
fontSizeLowerBound.current = 0;
|
||||
fontSizeUpperBound.current = parentDimensions.height;
|
||||
|
||||
let iterationCount = 0;
|
||||
|
||||
while (iterationCount <= ITERATION_LIMIT) {
|
||||
const childDimensions = getElementDimensions(childElement);
|
||||
|
||||
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
|
||||
|
||||
const widthDifference = parentDimensions.width - childDimensions.width;
|
||||
const heightDifference = targetHeight - childDimensions.height;
|
||||
|
||||
const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
|
||||
const isWithinTolerance =
|
||||
Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE || Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
|
||||
|
||||
if (childFitsIntoParent && isWithinTolerance) {
|
||||
break;
|
||||
}
|
||||
|
||||
adjustFontSize(childDimensions, parentDimensions);
|
||||
iterationCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(parentElement);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [maxHeight, useRem]);
|
||||
|
||||
return (
|
||||
<div ref={childRef} className={cn('inline-block leading-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user