@servicetitan/dte-pdf-editor 1.52.0 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +67 -51
  2. package/dist/components/display-conditions/condition-row.d.ts.map +1 -1
  3. package/dist/components/display-conditions/condition-row.js +35 -29
  4. package/dist/components/display-conditions/condition-row.js.map +1 -1
  5. package/dist/components/display-conditions/condition-value-control.d.ts +9 -0
  6. package/dist/components/display-conditions/condition-value-control.d.ts.map +1 -0
  7. package/dist/components/display-conditions/condition-value-control.js +33 -0
  8. package/dist/components/display-conditions/condition-value-control.js.map +1 -0
  9. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +1 -0
  10. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
  11. package/dist/components/field-config-panel/field-config-panel-overlay.js +2 -2
  12. package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
  13. package/dist/components/field-config-panel/field-config-panel.d.ts +1 -0
  14. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  15. package/dist/components/field-config-panel/field-config-panel.js +38 -15
  16. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  17. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  18. package/dist/components/pdf-editor/pdf-editor.js +1 -1
  19. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  20. package/dist/components/pdf-view/pdf-view-fillable.d.ts +2 -0
  21. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  22. package/dist/components/pdf-view/pdf-view-fillable.js +14 -10
  23. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  24. package/dist/components/pdf-view/pdf-view.d.ts +11 -0
  25. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  26. package/dist/components/pdf-view/pdf-view.js +2 -2
  27. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  28. package/dist/hooks/index.d.ts +1 -0
  29. package/dist/hooks/index.d.ts.map +1 -1
  30. package/dist/hooks/index.js +1 -0
  31. package/dist/hooks/index.js.map +1 -1
  32. package/dist/hooks/useLookupOptions.d.ts +23 -0
  33. package/dist/hooks/useLookupOptions.d.ts.map +1 -0
  34. package/dist/hooks/useLookupOptions.js +60 -0
  35. package/dist/hooks/useLookupOptions.js.map +1 -0
  36. package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
  37. package/dist/hooks/usePdfFieldDnD.js +5 -0
  38. package/dist/hooks/usePdfFieldDnD.js.map +1 -1
  39. package/dist/hooks/usePdfFieldSelection.d.ts +1 -0
  40. package/dist/hooks/usePdfFieldSelection.d.ts.map +1 -1
  41. package/dist/hooks/usePdfFieldSelection.js +4 -0
  42. package/dist/hooks/usePdfFieldSelection.js.map +1 -1
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/interface/host-events.d.ts +65 -0
  48. package/dist/interface/host-events.d.ts.map +1 -0
  49. package/dist/interface/host-events.js +75 -0
  50. package/dist/interface/host-events.js.map +1 -0
  51. package/dist/interface/types.d.ts +16 -0
  52. package/dist/interface/types.d.ts.map +1 -1
  53. package/dist/interface/types.js +5 -0
  54. package/dist/interface/types.js.map +1 -1
  55. package/dist/utils/conditions/schema-data-points.utils.d.ts +1 -0
  56. package/dist/utils/conditions/schema-data-points.utils.d.ts.map +1 -1
  57. package/dist/utils/conditions/schema-data-points.utils.js +30 -6
  58. package/dist/utils/conditions/schema-data-points.utils.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/components/display-conditions/condition-row.tsx +79 -41
  61. package/src/components/display-conditions/condition-value-control.tsx +90 -0
  62. package/src/components/field-config-panel/field-config-panel-overlay.tsx +6 -0
  63. package/src/components/field-config-panel/field-config-panel.tsx +61 -12
  64. package/src/components/pdf-editor/pdf-editor.tsx +1 -0
  65. package/src/components/pdf-view/pdf-view-fillable.tsx +18 -3
  66. package/src/components/pdf-view/pdf-view.tsx +8 -1
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/useLookupOptions.ts +86 -0
  69. package/src/hooks/usePdfFieldDnD.ts +5 -0
  70. package/src/hooks/usePdfFieldSelection.ts +8 -0
  71. package/src/index.ts +1 -0
  72. package/src/interface/host-events.ts +118 -0
  73. package/src/interface/types.ts +15 -0
  74. package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +35 -0
  75. package/src/utils/conditions/schema-data-points.utils.ts +38 -6
@@ -14,6 +14,7 @@ import {
14
14
  generateESignPath,
15
15
  generateFillablePath,
16
16
  getFieldFontSize,
17
+ parseFillablePathName,
17
18
  supportsFieldFontSize,
18
19
  } from '../../utils';
19
20
  import { DisplayCondition } from '../display-conditions/display-condition';
@@ -31,6 +32,10 @@ interface FieldConfigPanelProps {
31
32
  hideConditionalLogic?: boolean;
32
33
  onDeleteField(): void;
33
34
  onFieldConfigChange(updates: Partial<PdfField>): void;
35
+ onMatchingFieldsConfigChange?(
36
+ predicate: (field: PdfField) => boolean,
37
+ updates: Partial<PdfField>,
38
+ ): void;
34
39
  onRequestFormFields(formId: number): void;
35
40
  }
36
41
 
@@ -44,6 +49,7 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
44
49
  hideConditionalLogic = false,
45
50
  onDeleteField,
46
51
  onFieldConfigChange,
52
+ onMatchingFieldsConfigChange,
47
53
  onRequestFormFields,
48
54
  recipients = [],
49
55
  }) => {
@@ -61,20 +67,42 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
61
67
  [recipientOptions, field.recipient],
62
68
  );
63
69
 
70
+ const isRadioGroupSibling = (documentField: PdfField) =>
71
+ documentField.subType === 'radio' && documentField.path === field.path;
72
+
64
73
  const handleRecipientChange = (item: { id: string; name: string } | null) => {
65
- if (item) {
66
- let path = field.path;
67
- if (field.type === FieldTypeEnum.eSign) {
68
- path = generateESignPath(item.id, field.subType as ESignFieldType);
69
- }
70
- if (field.type === FieldTypeEnum.fillable) {
71
- path = generateFillablePath(item.id, field.id);
72
- }
73
- onFieldConfigChange({
74
- recipient: item.id,
75
- path,
76
- });
74
+ if (!item) {
75
+ return;
76
+ }
77
+
78
+ let path = field.path;
79
+ if (field.type === FieldTypeEnum.eSign) {
80
+ path = generateESignPath(item.id, field.subType as ESignFieldType);
81
+ }
82
+ if (field.type === FieldTypeEnum.fillable) {
83
+ const pathName =
84
+ field.subType === 'radio' && field.path
85
+ ? parseFillablePathName(field.path)
86
+ : field.id;
87
+ path = generateFillablePath(item.id, pathName);
88
+ }
89
+
90
+ const updates = {
91
+ recipient: item.id,
92
+ path,
93
+ };
94
+
95
+ if (
96
+ field.type === FieldTypeEnum.fillable &&
97
+ field.subType === 'radio' &&
98
+ field.path &&
99
+ onMatchingFieldsConfigChange
100
+ ) {
101
+ onMatchingFieldsConfigChange(isRadioGroupSibling, updates);
102
+ return;
77
103
  }
104
+
105
+ onFieldConfigChange(updates);
78
106
  };
79
107
 
80
108
  const selectedESignFieldType = useMemo(
@@ -104,6 +132,19 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
104
132
  }
105
133
  };
106
134
 
135
+ const handleRadioGroupNameChange = (groupName: string) => {
136
+ if (!field.path) {
137
+ return;
138
+ }
139
+
140
+ if (onMatchingFieldsConfigChange) {
141
+ onMatchingFieldsConfigChange(isRadioGroupSibling, { groupName });
142
+ return;
143
+ }
144
+
145
+ onFieldConfigChange({ groupName });
146
+ };
147
+
107
148
  return (
108
149
  <Flex direction="column" gap="4">
109
150
  {[
@@ -123,6 +164,14 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
123
164
  }
124
165
  />
125
166
  )}
167
+ {field.type === FieldTypeEnum.fillable && field.subType === 'radio' && (
168
+ <TextField
169
+ required
170
+ label="Radio Group Name"
171
+ value={field.groupName!}
172
+ onChange={e => handleRadioGroupNameChange(e.target.value)}
173
+ />
174
+ )}
126
175
  {(field.type === FieldTypeEnum.fillable || field.type === FieldTypeEnum.eSign) && (
127
176
  <Combobox
128
177
  items={recipientOptions}
@@ -87,6 +87,7 @@ export const PdfEditor: FC<PdfEditorProps> = ({
87
87
  formulaLoadingFormId={formFields.formulaLoadingFormId}
88
88
  selectedField={pdfFieldSelection.selectedField}
89
89
  onFieldConfigChange={pdfFieldSelection.updateField}
90
+ onMatchingFieldsConfigChange={pdfFieldSelection.updateMatchingFields}
90
91
  onDeleteField={pdfFieldSelection.deleteSelectedField}
91
92
  recipients={recipients}
92
93
  onDeselectField={pdfFieldSelection.deselectField}
@@ -5,6 +5,7 @@ import { dateToUtcZero, getFieldPlaceholderText, toDateInputValue } from '../../
5
5
  interface PdfViewFillableProps {
6
6
  field: PdfField;
7
7
  data?: DataModelValues;
8
+ readOnly?: boolean;
8
9
  fillingBy?: string[];
9
10
  viewMode?: PdfViewMode;
10
11
  onDataChange?(changedData: DataChangePayload, field: PdfField): void;
@@ -14,6 +15,7 @@ interface FillableTextInputProps {
14
15
  field: PdfField;
15
16
  value: string;
16
17
  disabled: boolean;
18
+ readOnly?: boolean;
17
19
  onChange(value: string): void;
18
20
  }
19
21
 
@@ -29,10 +31,12 @@ export const FillableTextInput: FC<FillableTextInputProps> = ({
29
31
  disabled,
30
32
  field,
31
33
  onChange,
34
+ readOnly,
32
35
  value,
33
36
  }) => {
34
37
  return (
35
38
  <input
39
+ readOnly={readOnly}
36
40
  type="text"
37
41
  style={fillableTextStyles}
38
42
  disabled={disabled}
@@ -49,10 +53,12 @@ export const FillableTextareaInput: FC<FillableTextInputProps> = ({
49
53
  disabled,
50
54
  field,
51
55
  onChange,
56
+ readOnly,
52
57
  value,
53
58
  }) => {
54
59
  return (
55
60
  <textarea
61
+ readOnly={readOnly}
56
62
  style={{ ...fillableTextStyles, resize: 'none' }}
57
63
  disabled={disabled}
58
64
  id={field.path}
@@ -69,6 +75,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
69
75
  field,
70
76
  fillingBy,
71
77
  onDataChange,
78
+ readOnly,
72
79
  viewMode,
73
80
  }) => {
74
81
  const resolvedValue = data?.[field.path!];
@@ -76,7 +83,12 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
76
83
  const isFillable =
77
84
  viewMode === 'preview' ? true : viewMode === 'fill' ? false : isViewByCurrentRecipient;
78
85
 
86
+ const isInputDisabled = !isFillable || !!readOnly;
87
+
79
88
  const handleDataChange = (fieldValue: DataChangePayload[string]) => {
89
+ if (readOnly) {
90
+ return;
91
+ }
80
92
  const changedData = { [field.path!]: fieldValue };
81
93
  onDataChange?.(changedData, field);
82
94
  };
@@ -101,9 +113,9 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
101
113
  }}
102
114
  type="checkbox"
103
115
  checked={resolvedValue ?? false}
104
- disabled={!isFillable}
116
+ disabled={isInputDisabled}
105
117
  name={field.path}
106
- onChange={e => handleDataChange?.(e!.target.checked)}
118
+ onChange={e => handleDataChange(e.target.checked)}
107
119
  />
108
120
  {field.label}
109
121
  </div>
@@ -131,7 +143,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
131
143
  type="radio"
132
144
  value={field.id}
133
145
  checked={resolvedValue === field.id}
134
- disabled={!isFillable}
146
+ disabled={isInputDisabled}
135
147
  name={field.path}
136
148
  onChange={() => handleDataChange(field.id)}
137
149
  />
@@ -144,6 +156,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
144
156
  const dateInputValue = toDateInputValue(resolvedValue);
145
157
  return (
146
158
  <input
159
+ readOnly={readOnly}
147
160
  type="date"
148
161
  style={{
149
162
  background: 'inherit',
@@ -168,6 +181,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
168
181
  if (field.subType === 'number') {
169
182
  return (
170
183
  <input
184
+ readOnly={readOnly}
171
185
  type="number"
172
186
  style={{
173
187
  background: 'inherit',
@@ -189,6 +203,7 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
189
203
  const TextComponent = field.multiline ? FillableTextareaInput : FillableTextInput;
190
204
  return (
191
205
  <TextComponent
206
+ readOnly={readOnly}
192
207
  field={field}
193
208
  value={resolvedValue ?? ''}
194
209
  disabled={!isFillable}
@@ -25,13 +25,18 @@ export interface PdfViewProps {
25
25
  data?: DataModelValues;
26
26
  pdfUrl: string;
27
27
  loading?: boolean;
28
+ /**
29
+ * If true, all fillable fields will be readonly.
30
+ * This is useful for previewing the form without allowing any changes.
31
+ */
32
+ makeFillableFieldsReadonly?: boolean;
28
33
  /**
29
34
  * UTC date strings (e.g. `'2026-03-17T00:00:00Z'`) treated as non-business days.
30
35
  * When provided, date-based calculated fields skip weekends and these holidays
31
36
  * when adding/subtracting days or computing date differences.
32
37
  */
33
38
  holidays?: string[];
34
- /*
39
+ /**
35
40
  * fillingBy defines the list of recipient names
36
41
  * who are allowed to fill this field.
37
42
  * This restriction is enforced only in 'fill' mode
@@ -54,6 +59,7 @@ export const PdfView: FC<PdfViewProps> = ({
54
59
  holidays,
55
60
  loading = false,
56
61
  loadingPlaceholder,
62
+ makeFillableFieldsReadonly = false,
57
63
  onDataChange,
58
64
  onLoadSuccess,
59
65
  pdfUrl,
@@ -122,6 +128,7 @@ export const PdfView: FC<PdfViewProps> = ({
122
128
  <PdfViewFillable
123
129
  data={viewMode === 'preview' ? previewData : data}
124
130
  field={field}
131
+ readOnly={makeFillableFieldsReadonly}
125
132
  fillingBy={fillingBy}
126
133
  viewMode={viewMode}
127
134
  onDataChange={handleDataChange}
@@ -1,6 +1,7 @@
1
1
  export * from './useFieldDrag';
2
2
  export * from './useFieldResize';
3
3
  export * from './useFormFields';
4
+ export * from './useLookupOptions';
4
5
  export * from './useFormulaEditor';
5
6
  export * from './usePdfDocumentRenderer';
6
7
  export * from './usePdfFieldDnD';
@@ -0,0 +1,86 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ createLookupRequestId,
4
+ emitLookupOptionsRequest,
5
+ isSearchableLookupSource,
6
+ type LookupOption,
7
+ onLookupOptionsResponse,
8
+ } from '../interface/host-events';
9
+
10
+ /**
11
+ * - `idle` -> no lookup source (or a searchable source we skip for now): use free-text input.
12
+ * - `loading` -> request in flight: show a disabled/loading value control.
13
+ * - `ready` -> host returned options: render the dropdown.
14
+ * - `fallback` -> empty/error/timeout: fall back to free-text so the user is never blocked.
15
+ */
16
+ export type LookupOptionsStatus = 'idle' | 'loading' | 'ready' | 'fallback';
17
+
18
+ /** Time to wait for the host before falling back to free-text input. */
19
+ const LOOKUP_OPTIONS_TIMEOUT_MS = 10_000;
20
+
21
+ interface UseLookupOptionsResult {
22
+ status: LookupOptionsStatus;
23
+ options: LookupOption[];
24
+ }
25
+
26
+ /**
27
+ * Requests host-resolved dropdown options for a field's `fieldLookupSource` and exposes the
28
+ * resulting render state.
29
+ *
30
+ * Re-runs whenever `sourceKey` changes, ignores stale responses whose `requestId` does not match
31
+ * the latest request, and tears down its listener/timeout on unmount or source change. Falls back
32
+ * to free text for empty/searchable sources and on error/timeout so the user is never blocked.
33
+ */
34
+ export const useLookupOptions = (sourceKey: string | undefined): UseLookupOptionsResult => {
35
+ const [status, setStatus] = useState<LookupOptionsStatus>('idle');
36
+ const [options, setOptions] = useState<LookupOption[]>([]);
37
+ const latestRequestIdRef = useRef<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ const normalizedSourceKey = sourceKey?.trim();
41
+ // No source, or a searchable source we intentionally skip for now.
42
+ if (!normalizedSourceKey || isSearchableLookupSource(normalizedSourceKey)) {
43
+ latestRequestIdRef.current = null;
44
+ setStatus('idle');
45
+ setOptions([]);
46
+ return;
47
+ }
48
+
49
+ const requestId = createLookupRequestId();
50
+ latestRequestIdRef.current = requestId;
51
+ setStatus('loading');
52
+ setOptions([]);
53
+
54
+ const timeoutId = setTimeout(() => {
55
+ if (latestRequestIdRef.current !== requestId) {
56
+ return;
57
+ }
58
+ setStatus('fallback');
59
+ setOptions([]);
60
+ }, LOOKUP_OPTIONS_TIMEOUT_MS);
61
+
62
+ const unsubscribe = onLookupOptionsResponse(response => {
63
+ // Ignore stale responses (different request, or superseded selection).
64
+ if (response.requestId !== requestId || latestRequestIdRef.current !== requestId) {
65
+ return;
66
+ }
67
+ clearTimeout(timeoutId);
68
+ if (response.options.length > 0) {
69
+ setOptions(response.options);
70
+ setStatus('ready');
71
+ } else {
72
+ setOptions([]);
73
+ setStatus('fallback');
74
+ }
75
+ });
76
+
77
+ emitLookupOptionsRequest({ requestId, sourceKey: normalizedSourceKey });
78
+
79
+ return () => {
80
+ clearTimeout(timeoutId);
81
+ unsubscribe();
82
+ };
83
+ }, [sourceKey]);
84
+
85
+ return { status, options };
86
+ };
@@ -43,6 +43,11 @@ const initializeDroppedFieldByType: Record<FieldTypeEnum, InitializeDroppedField
43
43
  recipient: recipientName,
44
44
  required: false,
45
45
  path: generateFillablePath(recipientName, uuidv4()),
46
+ ...(base.subType === 'radio'
47
+ ? {
48
+ groupName: `Radio Group ${uuidv4()}`,
49
+ }
50
+ : {}),
46
51
  ...(defaultSize && {
47
52
  width: defaultSize.width,
48
53
  height: defaultSize.height,
@@ -26,6 +26,13 @@ export const usePdfFieldSelection = (
26
26
  onFieldsChange(fields.map(f => (f.id === selectedFieldId ? { ...f, ...updates } : f)));
27
27
  };
28
28
 
29
+ const updateMatchingFields = (
30
+ predicate: (field: PdfField) => boolean,
31
+ updates: Partial<PdfField>,
32
+ ) => {
33
+ onFieldsChange(fields.map(f => (predicate(f) ? { ...f, ...updates } : f)));
34
+ };
35
+
29
36
  const deleteSelectedField = () => {
30
37
  if (!selectedFieldId) {
31
38
  return;
@@ -47,6 +54,7 @@ export const usePdfFieldSelection = (
47
54
  selectField,
48
55
  deselectField,
49
56
  updateField,
57
+ updateMatchingFields,
50
58
  deleteSelectedField,
51
59
  moveField,
52
60
  resizeField,
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './interface/types';
2
+ export * from './interface/host-events';
2
3
  export * from './components';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Host <-> editor contract for the "data-model lookup source" dropdown options (BYOD editor).
3
+ *
4
+ * A schema field can be tagged (in the template-type editor) with `options.fieldLookupSource`.
5
+ * When such a field is selected in the conditional-logic UI, the editor does NOT own the option
6
+ * data — it asks the HOST for it over the EventTarget bus below and renders whatever the host
7
+ * returns.
8
+ *
9
+ * Transport mirrors the unlayer `displayConditionController` / `lookupOptionsController` pattern: a
10
+ * module-scoped `EventTarget` shared by every consumer of this package (no global `window`
11
+ * pollution, no cross-instance leakage). The host imports `onLookupOptionsRequest` /
12
+ * `emitLookupOptionsResponse`; the editor uses `emitLookupOptionsRequest` / `onLookupOptionsResponse`.
13
+ *
14
+ * Event names (host <-> editor):
15
+ * - "dte-byod-client-mfe:lookup-options-request" (editor -> host)
16
+ * - "dte-byod-client-mfe:lookup-options-response" (host -> editor)
17
+ *
18
+ * The host-side resolver is implemented separately (it wires these events to the app's data
19
+ * providers, e.g. job types / states).
20
+ */
21
+
22
+ /** A single selectable option for a lookup-backed condition value. */
23
+ export interface LookupOption {
24
+ /** Human-readable text shown in the dropdown. */
25
+ label: string;
26
+ /** Value baked into the condition when the option is selected. */
27
+ key: string;
28
+ }
29
+
30
+ /** editor -> host: request the predefined options for a lookup source. */
31
+ export interface LookupOptionsRequest {
32
+ /** Correlates a response to its originating request. */
33
+ requestId: string;
34
+ /** The field's `options.fieldLookupSource` value. */
35
+ sourceKey: string;
36
+ /** Reserved for searchable sources (e.g. "city"); omitted for full-list sources. */
37
+ query?: string;
38
+ }
39
+
40
+ /** host -> editor: the resolved options for a previously emitted request. */
41
+ export interface LookupOptionsResponse {
42
+ /** Echoes {@link LookupOptionsRequest.requestId}; responses with a stale id are ignored. */
43
+ requestId: string;
44
+ options: LookupOption[];
45
+ }
46
+
47
+ /** editor -> host event name. */
48
+ export const LOOKUP_OPTIONS_REQUEST_EVENT = 'dte-byod-client-mfe:lookup-options-request';
49
+ /** host -> editor event name. */
50
+ export const LOOKUP_OPTIONS_RESPONSE_EVENT = 'dte-byod-client-mfe:lookup-options-response';
51
+
52
+ /**
53
+ * Lookup sources that require the host to search an API per typed query. These keep the free-text
54
+ * input for now (the editor only handles full-list, load-once sources).
55
+ */
56
+ export const SEARCHABLE_LOOKUP_SOURCES = ['city'] as const;
57
+
58
+ export type SearchableLookupSource = (typeof SEARCHABLE_LOOKUP_SOURCES)[number];
59
+
60
+ /** True when a lookup source is searchable (host-side per-query search) rather than a full list. */
61
+ export const isSearchableLookupSource = (sourceKey: string | undefined): boolean => {
62
+ return !!sourceKey && (SEARCHABLE_LOOKUP_SOURCES as readonly string[]).includes(sourceKey);
63
+ };
64
+
65
+ /** Generate a unique request id for correlating request/response pairs. */
66
+ export const createLookupRequestId = (): string => {
67
+ return `lookup-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
68
+ };
69
+
70
+ const lookupOptionsEvents = new EventTarget();
71
+
72
+ /** editor -> host: ask the host to resolve options for a lookup source. */
73
+ export const emitLookupOptionsRequest = (request: LookupOptionsRequest) => {
74
+ lookupOptionsEvents.dispatchEvent(
75
+ new CustomEvent<LookupOptionsRequest>(LOOKUP_OPTIONS_REQUEST_EVENT, { detail: request }),
76
+ );
77
+ };
78
+
79
+ /** host side: listen for editor requests. Returns an unsubscribe function. */
80
+ export const onLookupOptionsRequest = (listener: (request: LookupOptionsRequest) => void) => {
81
+ const handler = (event: Event) => {
82
+ const detail = (event as CustomEvent<LookupOptionsRequest>).detail;
83
+ if (!detail) {
84
+ return;
85
+ }
86
+ listener(detail);
87
+ };
88
+
89
+ lookupOptionsEvents.addEventListener(LOOKUP_OPTIONS_REQUEST_EVENT, handler);
90
+
91
+ return () => {
92
+ lookupOptionsEvents.removeEventListener(LOOKUP_OPTIONS_REQUEST_EVENT, handler);
93
+ };
94
+ };
95
+
96
+ /** host -> editor: deliver resolved options back to the editor. */
97
+ export const emitLookupOptionsResponse = (response: LookupOptionsResponse) => {
98
+ lookupOptionsEvents.dispatchEvent(
99
+ new CustomEvent<LookupOptionsResponse>(LOOKUP_OPTIONS_RESPONSE_EVENT, { detail: response }),
100
+ );
101
+ };
102
+
103
+ /** editor side: listen for host responses. Returns an unsubscribe function. */
104
+ export const onLookupOptionsResponse = (listener: (response: LookupOptionsResponse) => void) => {
105
+ const handler = (event: Event) => {
106
+ const detail = (event as CustomEvent<LookupOptionsResponse>).detail;
107
+ if (!detail) {
108
+ return;
109
+ }
110
+ listener(detail);
111
+ };
112
+
113
+ lookupOptionsEvents.addEventListener(LOOKUP_OPTIONS_RESPONSE_EVENT, handler);
114
+
115
+ return () => {
116
+ lookupOptionsEvents.removeEventListener(LOOKUP_OPTIONS_RESPONSE_EVENT, handler);
117
+ };
118
+ };
@@ -89,6 +89,7 @@ export interface PdfField {
89
89
  y: number;
90
90
  page: number;
91
91
  label: string;
92
+ groupName?: string;
92
93
  width: number;
93
94
  height: number;
94
95
  required?: boolean;
@@ -281,12 +282,26 @@ export type ConditionOperator =
281
282
  | (typeof STRING_OPERATORS)[number]['value']
282
283
  | (typeof NUMBER_OPERATORS)[number]['value'];
283
284
 
285
+ /** Operators for radio-group fillable fields. */
286
+ export const RADIO_OPERATORS = [
287
+ { label: 'Is equal to', value: 'is_equal_to' },
288
+ { label: 'Is not equal to', value: 'is_not_equal_to' },
289
+ ] as const;
290
+
284
291
  /** Operators that do not require a value (empty/is not empty) */
285
292
  export const VALUE_LESS_OPERATORS: ConditionOperator[] = ['is_empty', 'is_not_empty'];
286
293
 
294
+ export interface RadioGroupOption {
295
+ id: string;
296
+ label: string;
297
+ }
298
+
287
299
  export interface DataPointOption {
288
300
  fieldType: 'number' | 'string';
289
301
  fullKey: string;
290
302
  title: string;
291
303
  formSnapshot?: FormulaFieldFormSnapshot;
304
+ isRadioGroup?: boolean;
305
+ radioOptions?: RadioGroupOption[];
306
+ lookupSource?: string;
292
307
  }
@@ -122,6 +122,41 @@ describe('getDocumentFieldsDataPointOptions', () => {
122
122
  { fieldType: 'string', fullKey: 'fillable_a_b', title: 'fillable_a_b' },
123
123
  ]);
124
124
  });
125
+
126
+ test('groups radio fields with the same path into a single option using groupName', () => {
127
+ const sharedPath = 'fillable_signer1_payment';
128
+ const fields = [
129
+ buildField({
130
+ id: 'radio-yes',
131
+ path: sharedPath,
132
+ subType: 'radio',
133
+ label: 'Yes',
134
+ groupName: 'Payment Method',
135
+ }),
136
+ buildField({
137
+ id: 'radio-no',
138
+ path: sharedPath,
139
+ subType: 'radio',
140
+ label: 'No',
141
+ groupName: 'Payment Method',
142
+ }),
143
+ buildField({ id: 'text-1', path: 'fillable_signer1_name', label: 'Name', subType: 'text' }),
144
+ ];
145
+
146
+ expect(subject(fields)).toEqual([
147
+ { fieldType: 'string', fullKey: 'fillable_signer1_name', title: 'Name' },
148
+ {
149
+ fieldType: 'string',
150
+ fullKey: sharedPath,
151
+ title: 'Payment Method',
152
+ isRadioGroup: true,
153
+ radioOptions: [
154
+ { id: 'radio-no', label: 'No' },
155
+ { id: 'radio-yes', label: 'Yes' },
156
+ ],
157
+ },
158
+ ]);
159
+ });
125
160
  });
126
161
 
127
162
  describe('getDataPointOptions', () => {
@@ -18,7 +18,7 @@ function isUseInConditionals(
18
18
 
19
19
  function isValueNode(
20
20
  node: SchemaNode,
21
- ): node is { type: 'string' | 'number'; title?: string; options?: object } {
21
+ ): node is { type: 'string' | 'number'; title?: string; options?: { fieldLookupSource?: string } } {
22
22
  return node?.type === 'string' || node?.type === 'number';
23
23
  }
24
24
 
@@ -44,6 +44,7 @@ function walkSchema(
44
44
  fieldType,
45
45
  fullKey,
46
46
  title: fullTitle || fullKey,
47
+ lookupSource: node.options?.fieldLookupSource?.trim(),
47
48
  });
48
49
  }
49
50
  }
@@ -58,6 +59,7 @@ export function getSchemaDataPointOptions(schema: SchemaObject | undefined): Dat
58
59
  /**
59
60
  * Data point options from document fields: fillable only (no calculated fields).
60
61
  * Uses field.path as fullKey so condition evaluation can read from the same data object.
62
+ * Radio buttons sharing the same path are grouped into a single option using groupName.
61
63
  */
62
64
  export function getDocumentFieldsDataPointOptions(
63
65
  fields: PdfField[] | undefined,
@@ -66,17 +68,47 @@ export function getDocumentFieldsDataPointOptions(
66
68
  return [];
67
69
  }
68
70
  const result: DataPointOption[] = [];
69
- for (const f of fields) {
70
- if (!f.path || f.type !== FieldTypeEnum.fillable) {
71
+ const radioGroupsByPath = new Map<string, PdfField[]>();
72
+
73
+ for (const field of fields) {
74
+ if (!field.path || field.type !== FieldTypeEnum.fillable) {
75
+ continue;
76
+ }
77
+ if (field.subType === 'radio') {
78
+ const group = radioGroupsByPath.get(field.path) ?? [];
79
+ group.push(field);
80
+ radioGroupsByPath.set(field.path, group);
71
81
  continue;
72
82
  }
73
- const fieldType = f.subType === 'number' ? 'number' : 'string';
83
+ const fieldType = field.subType === 'number' ? 'number' : 'string';
74
84
  result.push({
75
85
  fieldType,
76
- fullKey: f.path,
77
- title: f.label || f.path,
86
+ fullKey: field.path,
87
+ title: field.label || field.path,
78
88
  });
79
89
  }
90
+
91
+ for (const [path, radioFields] of radioGroupsByPath) {
92
+ const sortedRadioFields = [...radioFields].sort((a, b) =>
93
+ (a.label || '').localeCompare(b.label || ''),
94
+ );
95
+ const groupName =
96
+ sortedRadioFields.find(radioField => radioField.groupName)?.groupName ??
97
+ sortedRadioFields[0]?.label ??
98
+ path;
99
+
100
+ result.push({
101
+ fieldType: 'string',
102
+ fullKey: path,
103
+ title: groupName,
104
+ isRadioGroup: true,
105
+ radioOptions: sortedRadioFields.map(radioField => ({
106
+ id: radioField.id,
107
+ label: radioField.label || radioField.id,
108
+ })),
109
+ });
110
+ }
111
+
80
112
  return result.sort((a, b) => a.title.localeCompare(b.title));
81
113
  }
82
114