Merge branch 'main' into feat/default-reset-button-document-preferences

This commit is contained in:
Catalin Pit
2026-06-29 10:11:25 +03:00
committed by GitHub
74 changed files with 1542 additions and 284 deletions
@@ -1,3 +1,4 @@
import { toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
@@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
href={toSafeHref(attachment.data)}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
@@ -1,5 +1,6 @@
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -1,5 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
/**
* Whether the field was automatically selected on creation (drag-drop or marquee).
*
* We purposefully supress the floating toolbar for newly created fields.
*/
const [isAutoSelectedField, setIsAutoSelectedField] = useState(false);
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
@@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// Set up field selection.
fieldGroup.on('click', () => {
// Set up field selection. Shift + click toggles this field in/out of the current
// multi-selection, so fields can be added to a group by clicking them --
// complementing marquee drag-selection. A plain click (no modifier) selects just
// this field.
fieldGroup.on('click', (event) => {
removePendingField();
setSelectedFields([fieldGroup]);
const isMultiSelectModifier = event.evt.shiftKey;
if (isMultiSelectModifier) {
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(fieldGroup);
setSelectedFields(
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
);
} else {
setSelectedFields([fieldGroup]);
}
pageLayer.current?.batchDraw();
});
@@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
});
// Clicks should select/deselect shapes
// Clicking empty stage area clears the selection. Field clicks -- including
// Shift+click multi-select -- are handled by each field group's own click
// handler in `unsafeRenderFieldOnLayer`.
currentStage.on('click tap', (e) => {
// if we are selecting with rect, do nothing
// If we are selecting with the marquee rectangle, do nothing.
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
return;
}
// If empty area clicked, remove all selections
// If empty area clicked, remove all selections.
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
// Do nothing if field not clicked, or if field is not editable
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
return;
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
setSelectedFields([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
setSelectedFields(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
setSelectedFields(nodes);
}
});
@@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields(liveSelectedFieldGroups);
}
// Mirror the editor's single selected field onto the canvas (Konva) selection.
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
if (fieldGroupToSelect instanceof Konva.Group) {
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
}, [
localPageFields,
selectedKonvaFieldGroups,
overlappingFieldFormIds,
isFieldChanging,
editorFields.selectedField?.formId,
]);
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
// Any explicit (user-driven) selection shows the action toolbar; only auto-selection
// on field creation suppresses it.
setIsAutoSelectedField(Boolean(options?.isAutoSelect));
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter(
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
@@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging &&
!isAutoSelectedField && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top:
interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left:
interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{pendingFieldCreation && (
<div
@@ -1,14 +1,17 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import {
BrandingPreferencesForm,
@@ -36,6 +39,8 @@ export default function TeamsSettingsPage() {
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
@@ -112,39 +117,61 @@ export default function TeamsSettingsPage() {
subtitle={t`Here you can set preferences and defaults for branding.`}
/>
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{canConfigureBranding ? (
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
<Trans>Branding Preferences</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
<AlertDescription className="mr-2">
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
</AlertDescription>
</Alert>
)}
</section>
</div>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
)}
</Alert>
)}
</div>
);
}
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.13.0"
"version": "2.14.0"
}
+2 -2
View File
@@ -146,7 +146,7 @@ export const downloadRoute = new Hono<HonoEnv>()
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/audit-log/pdf',
'/envelope/:envelopeId/audit-log/download',
sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
@@ -220,7 +220,7 @@ export const downloadRoute = new Hono<HonoEnv>()
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/certificate/pdf',
'/envelope/:envelopeId/certificate/download',
sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');