@servicetitan/dte-unlayer 0.94.0 → 0.96.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 (68) hide show
  1. package/dist/display-conditions/ConditionGroup.d.ts +12 -0
  2. package/dist/display-conditions/ConditionGroup.d.ts.map +1 -0
  3. package/dist/display-conditions/ConditionGroup.js +181 -0
  4. package/dist/display-conditions/ConditionGroup.js.map +1 -0
  5. package/dist/display-conditions/ConditionGroupsSection.d.ts +11 -0
  6. package/dist/display-conditions/ConditionGroupsSection.d.ts.map +1 -0
  7. package/dist/display-conditions/ConditionGroupsSection.js +71 -0
  8. package/dist/display-conditions/ConditionGroupsSection.js.map +1 -0
  9. package/dist/display-conditions/ConditionRow.d.ts +11 -0
  10. package/dist/display-conditions/ConditionRow.d.ts.map +1 -0
  11. package/dist/display-conditions/ConditionRow.js +206 -0
  12. package/dist/display-conditions/ConditionRow.js.map +1 -0
  13. package/dist/display-conditions/DisplayConditionModal.d.ts +5 -0
  14. package/dist/display-conditions/DisplayConditionModal.d.ts.map +1 -0
  15. package/dist/display-conditions/DisplayConditionModal.js +282 -0
  16. package/dist/display-conditions/DisplayConditionModal.js.map +1 -0
  17. package/dist/display-conditions/SeparatorWithChip.d.ts +6 -0
  18. package/dist/display-conditions/SeparatorWithChip.d.ts.map +1 -0
  19. package/dist/display-conditions/SeparatorWithChip.js +15 -0
  20. package/dist/display-conditions/SeparatorWithChip.js.map +1 -0
  21. package/dist/display-conditions/constants.d.ts +7 -0
  22. package/dist/display-conditions/constants.d.ts.map +1 -0
  23. package/dist/display-conditions/constants.js +22 -0
  24. package/dist/display-conditions/constants.js.map +1 -0
  25. package/dist/display-conditions/displayConditionController.d.ts +9 -0
  26. package/dist/display-conditions/displayConditionController.d.ts.map +1 -0
  27. package/dist/display-conditions/displayConditionController.js +29 -0
  28. package/dist/display-conditions/displayConditionController.js.map +1 -0
  29. package/dist/display-conditions/nunjucks.d.ts +8 -0
  30. package/dist/display-conditions/nunjucks.d.ts.map +1 -0
  31. package/dist/display-conditions/nunjucks.js +448 -0
  32. package/dist/display-conditions/nunjucks.js.map +1 -0
  33. package/dist/display-conditions/schemaDataPoints.d.ts +4 -0
  34. package/dist/display-conditions/schemaDataPoints.d.ts.map +1 -0
  35. package/dist/display-conditions/schemaDataPoints.js +18 -0
  36. package/dist/display-conditions/schemaDataPoints.js.map +1 -0
  37. package/dist/display-conditions/types.d.ts +130 -0
  38. package/dist/display-conditions/types.d.ts.map +1 -0
  39. package/dist/display-conditions/types.js +72 -0
  40. package/dist/display-conditions/types.js.map +1 -0
  41. package/dist/editor-core-source.d.ts +1 -1
  42. package/dist/editor-core-source.d.ts.map +1 -1
  43. package/dist/editor-core-source.js +1 -1
  44. package/dist/editor-core-source.js.map +1 -1
  45. package/dist/editor.d.ts.map +1 -1
  46. package/dist/editor.js +4 -0
  47. package/dist/editor.js.map +1 -1
  48. package/dist/shared/schema.d.ts +2 -0
  49. package/dist/shared/schema.d.ts.map +1 -1
  50. package/dist/shared/schema.js.map +1 -1
  51. package/dist/unlayer.d.ts.map +1 -1
  52. package/dist/unlayer.js +7 -0
  53. package/dist/unlayer.js.map +1 -1
  54. package/package.json +4 -2
  55. package/src/display-conditions/ConditionGroup.tsx +145 -0
  56. package/src/display-conditions/ConditionGroupsSection.tsx +64 -0
  57. package/src/display-conditions/ConditionRow.tsx +185 -0
  58. package/src/display-conditions/DisplayConditionModal.tsx +231 -0
  59. package/src/display-conditions/SeparatorWithChip.tsx +14 -0
  60. package/src/display-conditions/constants.ts +22 -0
  61. package/src/display-conditions/displayConditionController.ts +42 -0
  62. package/src/display-conditions/nunjucks.ts +503 -0
  63. package/src/display-conditions/schemaDataPoints.ts +33 -0
  64. package/src/display-conditions/types.ts +75 -0
  65. package/src/editor-core-source.ts +1 -1
  66. package/src/editor.tsx +2 -0
  67. package/src/shared/schema.ts +2 -0
  68. package/src/unlayer.tsx +9 -0
@@ -0,0 +1,185 @@
1
+ import { Button, Chip, Combobox, Flex, TextField } from '@servicetitan/anvil2';
2
+ import TrashIcon from '@servicetitan/anvil2/assets/icons/material/round/delete.svg';
3
+ import { useMemo } from 'react';
4
+ import { NUMBER_OPERATORS, SingleCondition, STRING_OPERATORS, VALUE_LESS_OPERATORS } from './types';
5
+ import type { DataPointOption } from './types';
6
+
7
+ export interface ConditionRowProps {
8
+ canRemove: boolean;
9
+ condition: SingleCondition;
10
+ dataPointOptions: DataPointOption[];
11
+ onChange: (c: SingleCondition) => void;
12
+ onRemove: () => void;
13
+ }
14
+
15
+ interface OperatorOption {
16
+ label: string;
17
+ value: string;
18
+ }
19
+
20
+ function sanitizeNumericInput(raw: string): string {
21
+ // Allow only a single leading minus and one decimal separator.
22
+ let value = raw.replaceAll(/[^0-9.-]/g, '');
23
+ const isNegative = value.startsWith('-');
24
+ value = value.replaceAll('-', '');
25
+ if (isNegative) {
26
+ value = `-${value}`;
27
+ }
28
+ const firstDot = value.indexOf('.');
29
+ if (firstDot >= 0) {
30
+ value = `${value.slice(0, firstDot + 1)}${value.slice(firstDot + 1).replaceAll('.', '')}`;
31
+ }
32
+ return value;
33
+ }
34
+
35
+ export function ConditionRow({
36
+ canRemove,
37
+ condition,
38
+ dataPointOptions,
39
+ onChange,
40
+ onRemove,
41
+ }: Readonly<ConditionRowProps>) {
42
+ const selectedDataPoint = useMemo(
43
+ () => dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey) ?? null,
44
+ [dataPointOptions, condition.dataPointKey],
45
+ );
46
+
47
+ const fieldType = selectedDataPoint?.fieldType ?? 'string';
48
+
49
+ const operatorItems: OperatorOption[] = useMemo(
50
+ () =>
51
+ fieldType === 'number'
52
+ ? ([...NUMBER_OPERATORS] as OperatorOption[])
53
+ : ([...STRING_OPERATORS] as OperatorOption[]),
54
+ [fieldType],
55
+ );
56
+
57
+ const selectedOperator = useMemo(
58
+ () => operatorItems.find(op => op.value === condition.operator) ?? null,
59
+ [operatorItems, condition.operator],
60
+ );
61
+
62
+ const isValueLess = VALUE_LESS_OPERATORS.includes(condition.operator);
63
+
64
+ const handleValueChange = (raw: string) => {
65
+ const value = fieldType === 'number' ? sanitizeNumericInput(raw) : raw;
66
+ onChange({ ...condition, value });
67
+ };
68
+
69
+ const handleDataPointChange = (item: DataPointOption | null) => {
70
+ const nextFieldType = item?.fieldType ?? 'string';
71
+ const nextIsNumber = nextFieldType === 'number';
72
+ const nextOperatorItems = nextIsNumber ? NUMBER_OPERATORS : STRING_OPERATORS;
73
+ const operatorReset = !nextOperatorItems.some(op => op.value === condition.operator);
74
+ onChange({
75
+ ...condition,
76
+ dataPointKey: item?.fullKey ?? '',
77
+ operator: operatorReset
78
+ ? nextIsNumber
79
+ ? 'num_eq'
80
+ : 'is_equal_to'
81
+ : condition.operator,
82
+ });
83
+ };
84
+
85
+ return (
86
+ <Flex
87
+ direction="row"
88
+ alignItems="flex-end"
89
+ gap="2"
90
+ style={{ flexWrap: 'wrap', rowGap: 8, width: '100%' }}
91
+ >
92
+ <div
93
+ style={{
94
+ display: 'flex',
95
+ alignItems: 'flex-end',
96
+ flexShrink: 0,
97
+ padding: '6px 12px',
98
+ }}
99
+ >
100
+ <Chip label="IF" size="medium" />
101
+ </div>
102
+ <div style={{ flex: '2 1 280px', minWidth: 240 }}>
103
+ <Combobox
104
+ {...({ disableClearSelection: true } as object)}
105
+ itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
106
+ itemToString={(item: DataPointOption | null) => item?.title ?? ''}
107
+ items={dataPointOptions}
108
+ selectedItem={selectedDataPoint}
109
+ onChange={handleDataPointChange}
110
+ >
111
+ <Combobox.SelectTrigger label="Data point" placeholder="Select data point..." />
112
+ <Combobox.Content>
113
+ {({ items }: { items: DataPointOption[] }) => (
114
+ <Combobox.List>
115
+ {items.map((item, i) => (
116
+ <Combobox.Item index={i} item={item} key={item.fullKey}>
117
+ {item.title}
118
+ </Combobox.Item>
119
+ ))}
120
+ </Combobox.List>
121
+ )}
122
+ </Combobox.Content>
123
+ </Combobox>
124
+ </div>
125
+ <div style={{ flex: '1 1 220px', minWidth: 180 }}>
126
+ <Combobox
127
+ {...({ disableClearSelection: true } as object)}
128
+ itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
129
+ itemToString={(item: OperatorOption | null) => item?.label ?? ''}
130
+ items={operatorItems}
131
+ selectedItem={selectedOperator}
132
+ onChange={(item: OperatorOption | null) =>
133
+ onChange({
134
+ ...condition,
135
+ operator: (item?.value ?? 'is_equal_to') as SingleCondition['operator'],
136
+ })
137
+ }
138
+ >
139
+ <Combobox.SelectTrigger label="Condition" placeholder="Select..." />
140
+ <Combobox.Content>
141
+ {({ items }: { items: OperatorOption[] }) => (
142
+ <Combobox.List>
143
+ {items.map((item, i) => (
144
+ <Combobox.Item index={i} item={item} key={item.value}>
145
+ {item.label}
146
+ </Combobox.Item>
147
+ ))}
148
+ </Combobox.List>
149
+ )}
150
+ </Combobox.Content>
151
+ </Combobox>
152
+ </div>
153
+ {!isValueLess && (
154
+ <div style={{ flex: '1 1 220px', minWidth: 180 }}>
155
+ <TextField
156
+ label="Value"
157
+ value={condition.value}
158
+ onChange={e => handleValueChange(e.target.value)}
159
+ placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
160
+ style={{ width: '100%' }}
161
+ aria-label="Value"
162
+ {...(fieldType === 'number' && { inputMode: 'decimal' })}
163
+ />
164
+ </div>
165
+ )}
166
+ {canRemove && (
167
+ <div
168
+ style={{
169
+ display: 'flex',
170
+ alignItems: 'flex-end',
171
+ flexShrink: 0,
172
+ padding: '6px 12px',
173
+ }}
174
+ >
175
+ <Button
176
+ appearance="secondary"
177
+ aria-label="Remove condition"
178
+ icon={{ before: TrashIcon }}
179
+ onClick={onRemove}
180
+ />
181
+ </div>
182
+ )}
183
+ </Flex>
184
+ );
185
+ }
@@ -0,0 +1,231 @@
1
+ import { Button, Combobox, Dialog, Flex, Text } from '@servicetitan/anvil2';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { ConditionGroupsSection } from './ConditionGroupsSection';
5
+ import { defaultGroup, defaultState, MODAL_CONTENT_MAX_HEIGHT } from './constants';
6
+ import { DisplayConditionRequest, onDisplayCondition } from './displayConditionController';
7
+ import { buildUnlayerDisplayCondition, parseUnlayerDisplayCondition } from './nunjucks';
8
+ import { getSchemaDataPointOptions } from './schemaDataPoints';
9
+ import { ConditionGroup as ConditionGroupType, DisplayBehavior } from './types';
10
+
11
+ const BEHAVIOR_OPTIONS = [
12
+ { label: 'Show', value: 'show' },
13
+ { label: 'Hide', value: 'hide' },
14
+ ] as const;
15
+ const NUMERIC_VALUE_RE = /^-?(?:\d+\.?\d*|\.\d+)$/;
16
+
17
+ type BehaviorOption = (typeof BEHAVIOR_OPTIONS)[number];
18
+
19
+ export interface DisplayConditionModalProps {
20
+ schema?: import('../shared/schema').SchemaObject;
21
+ }
22
+
23
+ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
24
+ const [request, setRequest] = useState<DisplayConditionRequest | null>(null);
25
+ const [isOpen, setIsOpen] = useState(false);
26
+ const [state, setState] = useState(defaultState);
27
+
28
+ const dataPointOptions = useMemo(() => getSchemaDataPointOptions(props.schema), [props.schema]);
29
+
30
+ const portalTarget = useMemo(() => {
31
+ if (typeof document === 'undefined') {
32
+ return null;
33
+ }
34
+ return document.body;
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ return onDisplayCondition(nextRequest => {
39
+ setRequest(nextRequest);
40
+ const existing = nextRequest.data;
41
+ const parsed = parseUnlayerDisplayCondition(existing);
42
+ setState(parsed ?? defaultState());
43
+ setIsOpen(true);
44
+ });
45
+ }, []);
46
+
47
+ const handleClose = useCallback(() => {
48
+ if (request) {
49
+ request.done(request.data ?? null);
50
+ setRequest(null);
51
+ }
52
+ setIsOpen(false);
53
+ }, [request]);
54
+
55
+ const handleSave = useCallback(() => {
56
+ if (!request) {
57
+ return;
58
+ }
59
+ const condition = buildUnlayerDisplayCondition(state);
60
+ if (condition) {
61
+ request.done(condition);
62
+ } else {
63
+ request.done(null);
64
+ }
65
+ setRequest(null);
66
+ setIsOpen(false);
67
+ }, [request, state]);
68
+
69
+ const updateGroup = useCallback((index: number, group: ConditionGroupType) => {
70
+ setState(prev => {
71
+ const next = [...prev.groups];
72
+ next[index] = group;
73
+ return { ...prev, groups: next };
74
+ });
75
+ }, []);
76
+
77
+ const removeGroup = useCallback((index: number) => {
78
+ setState(prev => ({
79
+ ...prev,
80
+ groups: (() => {
81
+ const nextGroups = prev.groups.filter((_, i) => i !== index);
82
+ if (nextGroups.length === 0) {
83
+ return [defaultGroup()];
84
+ }
85
+ if (index === 0) {
86
+ const { logicalOperator: unusedOp, ...firstGroup } = nextGroups[0];
87
+ return [firstGroup, ...nextGroups.slice(1)];
88
+ }
89
+ return nextGroups;
90
+ })(),
91
+ }));
92
+ }, []);
93
+
94
+ const addGroup = useCallback(() => {
95
+ setState(prev => ({
96
+ ...prev,
97
+ groups: [...prev.groups, { ...defaultGroup(), logicalOperator: 'and' }],
98
+ }));
99
+ }, []);
100
+
101
+ const selectedBehavior = useMemo(
102
+ () => BEHAVIOR_OPTIONS.find(opt => opt.value === state.behavior) ?? BEHAVIOR_OPTIONS[0],
103
+ [state.behavior],
104
+ );
105
+
106
+ const handleBehaviorChange = useCallback((item: BehaviorOption | null) => {
107
+ if (item) {
108
+ setState(prev => ({ ...prev, behavior: item.value as DisplayBehavior }));
109
+ }
110
+ }, []);
111
+
112
+ const canSave = useMemo(() => {
113
+ for (const group of state.groups) {
114
+ for (const condition of group.conditions) {
115
+ if (!condition.dataPointKey) {
116
+ return false;
117
+ }
118
+ if (condition.operator === 'is_empty' || condition.operator === 'is_not_empty') {
119
+ continue;
120
+ }
121
+ const trimmedValue = condition.value.trim();
122
+ if (!trimmedValue) {
123
+ return false;
124
+ }
125
+ const fieldType =
126
+ dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey)
127
+ ?.fieldType ?? 'string';
128
+ if (fieldType === 'number' && !NUMERIC_VALUE_RE.test(trimmedValue)) {
129
+ return false;
130
+ }
131
+ }
132
+ }
133
+ return state.groups.length > 0;
134
+ }, [dataPointOptions, state.groups]);
135
+
136
+ if (!portalTarget || !isOpen) {
137
+ return null;
138
+ }
139
+
140
+ return createPortal(
141
+ <Dialog open={isOpen} onClose={handleClose} size="xlarge">
142
+ <Dialog.Header>Rules and Conditions</Dialog.Header>
143
+ <Dialog.Content>
144
+ <Flex
145
+ direction="column"
146
+ gap="6"
147
+ style={{
148
+ maxHeight: MODAL_CONTENT_MAX_HEIGHT,
149
+ overflowY: 'auto',
150
+ width: '100%',
151
+ }}
152
+ >
153
+ <Flex
154
+ direction="column"
155
+ gap="3"
156
+ style={{
157
+ borderBottom: '1px solid #e0e0e0',
158
+ paddingBottom: 16,
159
+ }}
160
+ >
161
+ <Text size="medium" variant="body" style={{ fontWeight: 'bold' }}>
162
+ When conditions are met:
163
+ </Text>
164
+ <Flex
165
+ direction="row"
166
+ alignItems="center"
167
+ gap="3"
168
+ style={{ flexWrap: 'wrap' }}
169
+ >
170
+ <Text size="small" subdued variant="body">
171
+ Select to show or hide selected component.
172
+ </Text>
173
+ <div style={{ width: 160 }}>
174
+ <Combobox
175
+ {...({ disableClearSelection: true } as object)}
176
+ itemToKey={(item: BehaviorOption | null) => item?.value ?? ''}
177
+ itemToString={(item: BehaviorOption | null) =>
178
+ item?.label ?? ''
179
+ }
180
+ items={[...BEHAVIOR_OPTIONS]}
181
+ selectedItem={selectedBehavior}
182
+ onChange={handleBehaviorChange}
183
+ >
184
+ <Combobox.SelectTrigger
185
+ label="Behavior"
186
+ placeholder="Select..."
187
+ />
188
+ <Combobox.Content>
189
+ {({ items }: { items: BehaviorOption[] }) => (
190
+ <Combobox.List>
191
+ {items.map((item, i) => (
192
+ <Combobox.Item
193
+ index={i}
194
+ item={item}
195
+ key={item.value}
196
+ >
197
+ {item.label}
198
+ </Combobox.Item>
199
+ ))}
200
+ </Combobox.List>
201
+ )}
202
+ </Combobox.Content>
203
+ </Combobox>
204
+ </div>
205
+ </Flex>
206
+ </Flex>
207
+ <ConditionGroupsSection
208
+ dataPointOptions={dataPointOptions}
209
+ groups={state.groups}
210
+ onAddGroup={addGroup}
211
+ onRemoveGroup={removeGroup}
212
+ onUpdateGroup={updateGroup}
213
+ />
214
+ {!canSave && (
215
+ <Text size="small" subdued variant="body">
216
+ Complete each condition before saving. Data point and value are
217
+ required, and numeric fields must contain a valid number.
218
+ </Text>
219
+ )}
220
+ </Flex>
221
+ </Dialog.Content>
222
+ <Dialog.Footer sticky>
223
+ <Dialog.CancelButton onClick={handleClose}>Cancel</Dialog.CancelButton>
224
+ <Button appearance="primary" disabled={!canSave} onClick={handleSave}>
225
+ Save Changes
226
+ </Button>
227
+ </Dialog.Footer>
228
+ </Dialog>,
229
+ portalTarget,
230
+ );
231
+ };
@@ -0,0 +1,14 @@
1
+ import { Chip, Divider } from '@servicetitan/anvil2';
2
+
3
+ export interface SeparatorWithChipProps {
4
+ label: string;
5
+ color?: string;
6
+ }
7
+
8
+ export function SeparatorWithChip({ color = '#94a3b8', label }: Readonly<SeparatorWithChipProps>) {
9
+ return (
10
+ <Divider alignContent="center" spacing="2">
11
+ <Chip label={label} size="small" color={color} />
12
+ </Divider>
13
+ );
14
+ }
@@ -0,0 +1,22 @@
1
+ import { ConditionGroup, DisplayConditionState, SingleCondition } from './types';
2
+
3
+ export const generateId = () => Math.random().toString(36).slice(2, 11);
4
+
5
+ export const defaultCondition = (): SingleCondition => ({
6
+ dataPointKey: '',
7
+ id: generateId(),
8
+ operator: 'is_equal_to',
9
+ value: '',
10
+ });
11
+
12
+ export const defaultGroup = (): ConditionGroup => ({
13
+ conditions: [defaultCondition()],
14
+ id: generateId(),
15
+ });
16
+
17
+ export const defaultState = (): DisplayConditionState => ({
18
+ behavior: 'show',
19
+ groups: [defaultGroup()],
20
+ });
21
+
22
+ export const MODAL_CONTENT_MAX_HEIGHT = '70vh';
@@ -0,0 +1,42 @@
1
+ export interface DisplayConditionRequest {
2
+ data: any;
3
+ done: (condition: any) => void;
4
+ }
5
+
6
+ type Listener = (request: DisplayConditionRequest) => void;
7
+
8
+ const DISPLAY_CONDITION_EVENT = 'dte:display-condition';
9
+ const displayConditionEvents = new EventTarget();
10
+ let activeListeners = 0;
11
+
12
+ export const onDisplayCondition = (listener: Listener) => {
13
+ const handler = (event: Event) => {
14
+ const detail = (event as CustomEvent<DisplayConditionRequest>).detail;
15
+ if (!detail) {
16
+ return;
17
+ }
18
+ listener(detail);
19
+ };
20
+
21
+ activeListeners += 1;
22
+ displayConditionEvents.addEventListener(DISPLAY_CONDITION_EVENT, handler as EventListener);
23
+
24
+ return () => {
25
+ activeListeners = Math.max(0, activeListeners - 1);
26
+ displayConditionEvents.removeEventListener(
27
+ DISPLAY_CONDITION_EVENT,
28
+ handler as EventListener,
29
+ );
30
+ };
31
+ };
32
+
33
+ export const emitDisplayCondition = (request: DisplayConditionRequest) => {
34
+ if (activeListeners === 0) {
35
+ request.done(request.data);
36
+ return;
37
+ }
38
+
39
+ displayConditionEvents.dispatchEvent(
40
+ new CustomEvent<DisplayConditionRequest>(DISPLAY_CONDITION_EVENT, { detail: request }),
41
+ );
42
+ };