From a26a740fe57241487bef6213a1cda0623ca08b8a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 15 Oct 2025 11:17:57 +1100 Subject: [PATCH] feat: add horizontal radio --- .../dialogs/envelope-distribute-dialog.tsx | 27 +++-- .../forms/editor/editor-field-radio-form.tsx | 104 +++++++++++++----- .../envelope-editor-fields-drag-drop.tsx | 22 +++- .../envelope-editor-page-upload.tsx | 8 +- .../envelope-editor/envelope-editor.tsx | 13 ++- .../providers/envelope-editor-provider.tsx | 14 ++- packages/lib/types/field-meta.ts | 2 + .../field-renderer/field-renderer.ts | 40 +++++++ .../field-renderer/render-checkbox-field.ts | 8 +- .../field-renderer/render-radio-field.ts | 8 +- packages/ui/lib/recipient-colors.ts | 8 ++ 11 files changed, 196 insertions(+), 58 deletions(-) diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index a3aac4436..2e8451c9c 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu const distributionMethod = watch('meta.distributionMethod'); - const everySignerHasSignature = useMemo( + const recipientsMissingSignatureFields = useMemo( () => - envelope.recipients - .filter((recipient) => recipient.role === RecipientRole.SIGNER) - .every((recipient) => - envelope.fields.some( + envelope.recipients.filter( + (recipient) => + recipient.role === RecipientRole.SIGNER && + !envelope.fields.some( (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, ), - ), + ), [envelope.recipients, envelope.fields], ); @@ -178,7 +178,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu Recipients will be able to sign the document once sent - {everySignerHasSignature ? ( + {recipientsMissingSignatureFields.length === 0 ? (
@@ -350,6 +350,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu ) : (
    + {/* Todo: Envelopes - I don't think this section shows up */} + {recipients.length === 0 && (
  • No recipients @@ -427,10 +429,13 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu <> - - Some signers have not been assigned a signature field. Please assign at least 1 - signature field to each signer before proceeding. - + The following signers are missing signature fields: + +
      + {recipientsMissingSignatureFields.map((recipient) => ( +
    • {recipient.email}
    • + ))} +
    diff --git a/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx b/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx index 8669b3e52..43d21cc70 100644 --- a/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx @@ -1,15 +1,32 @@ import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { PlusIcon, Trash } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import type { z } from 'zod'; -import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta'; +import { + type TRadioFieldMeta as RadioFieldMeta, + ZRadioFieldMeta, +} from '@documenso/lib/types/field-meta'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; -import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; import { Separator } from '@documenso/ui/primitives/separator'; import { @@ -17,31 +34,26 @@ import { EditorGenericRequiredField, } from './editor-field-generic-field-forms'; -const ZRadioFieldFormSchema = z - .object({ - label: z.string().optional(), - values: z - .object({ id: z.number(), checked: z.boolean(), value: z.string() }) - .array() - .min(1) - .optional(), - required: z.boolean().optional(), - readOnly: z.boolean().optional(), - }) - .refine( - (data) => { - // There cannot be more than one checked option - if (data.values) { - const checkedValues = data.values.filter((option) => option.checked); - return checkedValues.length <= 1; - } - return true; - }, - { - message: 'There cannot be more than one checked option', - path: ['values'], - }, - ); +const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({ + label: true, + direction: true, + values: true, + required: true, + readOnly: true, +}).refine( + (data) => { + // There cannot be more than one checked option + if (data.values) { + const checkedValues = data.values.filter((option) => option.checked); + return checkedValues.length <= 1; + } + return true; + }, + { + message: 'There cannot be more than one checked option', + path: ['values'], + }, +); type TRadioFieldFormSchema = z.infer; @@ -53,9 +65,12 @@ export type EditorFieldRadioFormProps = { export const EditorFieldRadioForm = ({ value = { type: 'radio', + direction: 'vertical', }, onValueChange, }: EditorFieldRadioFormProps) => { + const { t } = useLingui(); + const form = useForm({ resolver: zodResolver(ZRadioFieldFormSchema), mode: 'onChange', @@ -64,6 +79,7 @@ export const EditorFieldRadioForm = ({ values: value.values || [{ id: 1, checked: false, value: 'Default value' }], required: value.required || false, readOnly: value.readOnly || false, + direction: value.direction || 'vertical', }, }); @@ -107,7 +123,35 @@ export const EditorFieldRadioForm = ({ return ( -
    +
    + ( + + + Direction + + + + + + + )} + /> + diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx index dcb4f23b2..8e1eec5e4 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx @@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({ selectedRecipientId, selectedEnvelopeItemId, }: EnvelopeEditorFieldDragDropProps) => { - const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor(); + const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { t } = useLingui(); @@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({ }; }, [onMouseClick, onMouseMove, selectedField]); + const selectedRecipientColor = useMemo(() => { + return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green'; + }, [selectedRecipientId, getRecipientColorKey]); + return ( <>
    @@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({ onClick={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)} data-selected={selectedField === field.type ? true : undefined} - className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50" + className={cn( + 'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors', + RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton, + )} >

    {field.type !== FieldType.SIGNATURE && } @@ -292,8 +307,7 @@ export const EnvelopeEditorFieldDragDrop = ({

    {
    - Documents - Add and configure multiple documents + + Documents + + + Add and configure multiple documents + diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx index 1fb98a3e7..8c2ff061a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx @@ -128,6 +128,18 @@ export default function EnvelopeEditor() { } }; + // Watch the URL params and setStep if the step changes. + useEffect(() => { + const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id; + + const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam); + + if (foundStep && foundStep.id !== currentStep) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + navigateToStep(foundStep.id as EnvelopeEditorStep); + } + }, [searchParams]); + useEffect(() => { if (!isAutosaving) { setIsStepLoading(false); @@ -340,7 +352,6 @@ export default function EnvelopeEditor() { {/* Main Content - Changes based on current step */}
    -

    {isAutosaving ? 'Autosaving...' : 'Not autosaving'}

    {match({ currentStep, isStepLoading }) .with({ isStepLoading: true }, () => ) diff --git a/packages/lib/client-only/providers/envelope-editor-provider.tsx b/packages/lib/client-only/providers/envelope-editor-provider.tsx index 9344dc84c..8c6c6b842 100644 --- a/packages/lib/client-only/providers/envelope-editor-provider.tsx +++ b/packages/lib/client-only/providers/envelope-editor-provider.tsx @@ -135,7 +135,12 @@ export const EnvelopeEditorProvider = ({ }); const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({ - onSuccess: () => { + onSuccess: ({ recipients }) => { + setEnvelope((prev) => ({ + ...prev, + recipients, + })); + setAutosaveError(false); }, onError: (error) => { @@ -215,14 +220,15 @@ export const EnvelopeEditorProvider = ({ const getRecipientColorKey = useCallback( (recipientId: number) => { - // Todo: Envelopes - Local recipients const recipientIndex = envelope.recipients.findIndex( (recipient) => recipient.id === recipientId, ); - return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)]; + return AVAILABLE_RECIPIENT_COLORS[ + Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length + ]; }, - [envelope.recipients], // Todo: Envelopes - Local recipients + [envelope.recipients], ); const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery( diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index eea0d204f..cae4b48be 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -81,6 +81,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({ }), ) .optional(), + direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'), }); export type TRadioFieldMeta = z.infer; @@ -278,6 +279,7 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = { values: [{ id: 1, checked: false, value: '' }], required: false, readOnly: false, + direction: 'vertical', }; export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = { diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts index e5f236c5a..b19a1bcc6 100644 --- a/packages/lib/universal/field-renderer/field-renderer.ts +++ b/packages/lib/universal/field-renderer/field-renderer.ts @@ -107,6 +107,11 @@ type CalculateMultiItemPositionOptions = { */ fieldPadding: number; + /** + * The direction of the items. + */ + direction: 'horizontal' | 'vertical'; + type: 'checkbox' | 'radio'; }; @@ -122,6 +127,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp itemSize, spacingBetweenItemAndText, fieldPadding, + direction, type, } = options; @@ -130,6 +136,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp const innerFieldX = fieldPadding; const innerFieldY = fieldPadding; + if (direction === 'horizontal') { + const itemHeight = innerFieldHeight; + const itemWidth = innerFieldWidth / itemCount; + + const y = innerFieldY; + const x = itemIndex * itemWidth + innerFieldX; + + let itemInputY = y + itemHeight / 2 - itemSize / 2; + let itemInputX = x; + + // We need a little different logic to center the radio circle icon. + if (type === 'radio') { + itemInputX = x + itemSize / 2; + itemInputY = y + itemHeight / 2; + } + + const textX = x + itemSize + spacingBetweenItemAndText; + const textY = y; + + // Multiplied by 2 for extra padding on the right hand side of the text and the next item. + const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2; + const textHeight = itemHeight; + + return { + itemInputX, + itemInputY, + textX, + textY, + textWidth, + textHeight, + }; + } + const itemHeight = innerFieldHeight / itemCount; const y = itemIndex * itemHeight + innerFieldY; @@ -137,6 +176,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp let itemInputY = y + itemHeight / 2 - itemSize / 2; let itemInputX = innerFieldX; + // We need a little different logic to center the radio circle icon. if (type === 'radio') { itemInputX = innerFieldX + itemSize / 2; itemInputY = y + itemHeight / 2; diff --git a/packages/lib/universal/field-renderer/render-checkbox-field.ts b/packages/lib/universal/field-renderer/render-checkbox-field.ts index 5c428ed0b..05adb05b0 100644 --- a/packages/lib/universal/field-renderer/render-checkbox-field.ts +++ b/packages/lib/universal/field-renderer/render-checkbox-field.ts @@ -26,6 +26,9 @@ export const renderCheckboxFieldElement = ( fieldGroup.add(upsertFieldRect(field, options)); + const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null; + const checkboxValues = checkboxMeta?.values || []; + if (isFirstRender) { pageLayer.add(fieldGroup); @@ -72,6 +75,7 @@ export const renderCheckboxFieldElement = ( itemSize: checkboxSize, spacingBetweenItemAndText: spacingBetweenCheckboxAndText, fieldPadding: checkboxFieldPadding, + direction: checkboxMeta?.direction || 'vertical', type: 'checkbox', }); @@ -113,9 +117,6 @@ export const renderCheckboxFieldElement = ( }); } - const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null; - const checkboxValues = checkboxMeta?.values || []; - const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); checkboxValues.forEach(({ id, value, checked }, index) => { @@ -128,6 +129,7 @@ export const renderCheckboxFieldElement = ( itemSize: checkboxSize, spacingBetweenItemAndText: spacingBetweenCheckboxAndText, fieldPadding: checkboxFieldPadding, + direction: checkboxMeta?.direction || 'vertical', type: 'checkbox', }); diff --git a/packages/lib/universal/field-renderer/render-radio-field.ts b/packages/lib/universal/field-renderer/render-radio-field.ts index c845682b2..a9e8c3950 100644 --- a/packages/lib/universal/field-renderer/render-radio-field.ts +++ b/packages/lib/universal/field-renderer/render-radio-field.ts @@ -26,6 +26,9 @@ export const renderRadioFieldElement = ( fieldGroup.add(upsertFieldRect(field, options)); + const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null; + const radioValues = radioMeta?.values || []; + if (isFirstRender) { pageLayer.add(fieldGroup); @@ -66,6 +69,7 @@ export const renderRadioFieldElement = ( spacingBetweenItemAndText: spacingBetweenRadioAndText, fieldPadding: radioFieldPadding, type: 'radio', + direction: radioMeta?.direction || 'vertical', }); circleElement.setAttrs({ @@ -104,9 +108,6 @@ export const renderRadioFieldElement = ( }); } - const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null; - const radioValues = radioMeta?.values || []; - const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); radioValues.forEach(({ value, checked }, index) => { @@ -120,6 +121,7 @@ export const renderRadioFieldElement = ( spacingBetweenItemAndText: spacingBetweenRadioAndText, fieldPadding: radioFieldPadding, type: 'radio', + direction: radioMeta?.direction || 'vertical', }); // Circle which represents the radio button. diff --git a/packages/ui/lib/recipient-colors.ts b/packages/ui/lib/recipient-colors.ts index f8fb810e3..c28abbcb4 100644 --- a/packages/ui/lib/recipient-colors.ts +++ b/packages/ui/lib/recipient-colors.ts @@ -9,6 +9,7 @@ export type RecipientColorStyles = { base: string; baseRing: string; baseRingHover: string; + fieldButton: string; fieldItem: string; fieldItemInitials: string; comboxBoxTrigger: string; @@ -23,6 +24,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-neutral-400', baseRing: 'rgba(176, 176, 176, 1)', baseRingHover: 'rgba(176, 176, 176, 1)', + fieldButton: 'border-neutral-400 hover:border-neutral-400', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: '', comboxBoxTrigger: @@ -34,6 +36,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-green hover:bg-recipient-green/30', baseRing: 'rgba(122, 195, 85, 1)', baseRingHover: 'rgba(122, 195, 85, 0.3)', + fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-green', comboxBoxTrigger: @@ -45,6 +48,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-blue hover:bg-recipient-blue/30', baseRing: 'rgba(56, 123, 199, 1)', baseRingHover: 'rgba(56, 123, 199, 0.3)', + fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-blue', comboxBoxTrigger: @@ -56,6 +60,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-purple hover:bg-recipient-purple/30', baseRing: 'rgba(151, 71, 255, 1)', baseRingHover: 'rgba(151, 71, 255, 0.3)', + fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-purple', comboxBoxTrigger: @@ -67,6 +72,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-orange hover:bg-recipient-orange/30', baseRing: 'rgba(246, 159, 30, 1)', baseRingHover: 'rgba(246, 159, 30, 0.3)', + fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-orange', comboxBoxTrigger: @@ -78,6 +84,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-yellow hover:bg-recipient-yellow/30', baseRing: 'rgba(219, 186, 0, 1)', baseRingHover: 'rgba(219, 186, 0, 0.3)', + fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow', comboxBoxTrigger: @@ -89,6 +96,7 @@ export const RECIPIENT_COLOR_STYLES = { base: 'ring-recipient-pink hover:bg-recipient-pink/30', baseRing: 'rgba(217, 74, 186, 1)', baseRingHover: 'rgba(217, 74, 186, 0.3)', + fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30', fieldItem: 'group/field-item rounded-[2px]', fieldItemInitials: 'group-hover/field-item:bg-recipient-pink', comboxBoxTrigger: