@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.
- package/README.md +67 -51
- package/dist/components/display-conditions/condition-row.d.ts.map +1 -1
- package/dist/components/display-conditions/condition-row.js +35 -29
- package/dist/components/display-conditions/condition-row.js.map +1 -1
- package/dist/components/display-conditions/condition-value-control.d.ts +9 -0
- package/dist/components/display-conditions/condition-value-control.d.ts.map +1 -0
- package/dist/components/display-conditions/condition-value-control.js +33 -0
- package/dist/components/display-conditions/condition-value-control.js.map +1 -0
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +1 -0
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.js +2 -2
- package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.d.ts +1 -0
- package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.js +38 -15
- package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.js +1 -1
- package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.d.ts +2 -0
- package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.js +14 -10
- package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +11 -0
- package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view.js +2 -2
- package/dist/components/pdf-view/pdf-view.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/useLookupOptions.d.ts +23 -0
- package/dist/hooks/useLookupOptions.d.ts.map +1 -0
- package/dist/hooks/useLookupOptions.js +60 -0
- package/dist/hooks/useLookupOptions.js.map +1 -0
- package/dist/hooks/usePdfFieldDnD.d.ts.map +1 -1
- package/dist/hooks/usePdfFieldDnD.js +5 -0
- package/dist/hooks/usePdfFieldDnD.js.map +1 -1
- package/dist/hooks/usePdfFieldSelection.d.ts +1 -0
- package/dist/hooks/usePdfFieldSelection.d.ts.map +1 -1
- package/dist/hooks/usePdfFieldSelection.js +4 -0
- package/dist/hooks/usePdfFieldSelection.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interface/host-events.d.ts +65 -0
- package/dist/interface/host-events.d.ts.map +1 -0
- package/dist/interface/host-events.js +75 -0
- package/dist/interface/host-events.js.map +1 -0
- package/dist/interface/types.d.ts +16 -0
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js +5 -0
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/conditions/schema-data-points.utils.d.ts +1 -0
- package/dist/utils/conditions/schema-data-points.utils.d.ts.map +1 -1
- package/dist/utils/conditions/schema-data-points.utils.js +30 -6
- package/dist/utils/conditions/schema-data-points.utils.js.map +1 -1
- package/package.json +1 -1
- package/src/components/display-conditions/condition-row.tsx +79 -41
- package/src/components/display-conditions/condition-value-control.tsx +90 -0
- package/src/components/field-config-panel/field-config-panel-overlay.tsx +6 -0
- package/src/components/field-config-panel/field-config-panel.tsx +61 -12
- package/src/components/pdf-editor/pdf-editor.tsx +1 -0
- package/src/components/pdf-view/pdf-view-fillable.tsx +18 -3
- package/src/components/pdf-view/pdf-view.tsx +8 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useLookupOptions.ts +86 -0
- package/src/hooks/usePdfFieldDnD.ts +5 -0
- package/src/hooks/usePdfFieldSelection.ts +8 -0
- package/src/index.ts +1 -0
- package/src/interface/host-events.ts +118 -0
- package/src/interface/types.ts +15 -0
- package/src/utils/conditions/__tests__/schema-data-points.utils.test.ts +35 -0
- 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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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={
|
|
116
|
+
disabled={isInputDisabled}
|
|
105
117
|
name={field.path}
|
|
106
|
-
onChange={e => handleDataChange
|
|
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={
|
|
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}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
};
|
package/src/interface/types.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
70
|
-
|
|
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 =
|
|
83
|
+
const fieldType = field.subType === 'number' ? 'number' : 'string';
|
|
74
84
|
result.push({
|
|
75
85
|
fieldType,
|
|
76
|
-
fullKey:
|
|
77
|
-
title:
|
|
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
|
|