@fleetbase/ember-ui 0.3.22 → 0.3.24

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 (46) hide show
  1. package/addon/components/layout/header/smart-nav-menu/dropdown.hbs +77 -68
  2. package/addon/components/layout/header/smart-nav-menu/dropdown.js +7 -54
  3. package/addon/components/template-builder/canvas.hbs +23 -0
  4. package/addon/components/template-builder/canvas.js +116 -0
  5. package/addon/components/template-builder/element-renderer.hbs +126 -0
  6. package/addon/components/template-builder/element-renderer.js +398 -0
  7. package/addon/components/template-builder/layers-panel.hbs +99 -0
  8. package/addon/components/template-builder/layers-panel.js +146 -0
  9. package/addon/components/template-builder/properties-panel/field.hbs +7 -0
  10. package/addon/components/template-builder/properties-panel/field.js +9 -0
  11. package/addon/components/template-builder/properties-panel/section.hbs +24 -0
  12. package/addon/components/template-builder/properties-panel/section.js +19 -0
  13. package/addon/components/template-builder/properties-panel.hbs +576 -0
  14. package/addon/components/template-builder/properties-panel.js +413 -0
  15. package/addon/components/template-builder/queries-panel.hbs +84 -0
  16. package/addon/components/template-builder/queries-panel.js +88 -0
  17. package/addon/components/template-builder/query-form.hbs +260 -0
  18. package/addon/components/template-builder/query-form.js +309 -0
  19. package/addon/components/template-builder/toolbar.hbs +134 -0
  20. package/addon/components/template-builder/toolbar.js +106 -0
  21. package/addon/components/template-builder/variable-picker.hbs +210 -0
  22. package/addon/components/template-builder/variable-picker.js +181 -0
  23. package/addon/components/template-builder.hbs +119 -0
  24. package/addon/components/template-builder.js +567 -0
  25. package/addon/helpers/string-starts-with.js +14 -0
  26. package/addon/services/template-builder.js +72 -0
  27. package/addon/styles/addon.css +1 -0
  28. package/addon/styles/components/badge.css +66 -12
  29. package/addon/styles/components/smart-nav-menu.css +35 -29
  30. package/addon/styles/components/template-builder.css +297 -0
  31. package/addon/utils/get-currency.js +1 -1
  32. package/app/components/template-builder/canvas.js +1 -0
  33. package/app/components/template-builder/element-renderer.js +1 -0
  34. package/app/components/template-builder/layers-panel.js +1 -0
  35. package/app/components/template-builder/properties-panel/field.js +1 -0
  36. package/app/components/template-builder/properties-panel/section.js +1 -0
  37. package/app/components/template-builder/properties-panel.js +1 -0
  38. package/app/components/template-builder/queries-panel.js +1 -0
  39. package/app/components/template-builder/query-form.js +1 -0
  40. package/app/components/template-builder/toolbar.js +1 -0
  41. package/app/components/template-builder/variable-picker.js +1 -0
  42. package/app/components/template-builder.js +1 -0
  43. package/app/helpers/string-starts-with.js +1 -0
  44. package/app/services/template-builder.js +1 -0
  45. package/package.json +3 -2
  46. package/tsconfig.declarations.json +8 -8
@@ -0,0 +1,134 @@
1
+ {{! Template Builder Toolbar }}
2
+ <div class="tb-toolbar flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900" ...attributes>
3
+
4
+ {{! Left: Close/back button (optional) + Element type buttons }}
5
+ <div class="flex items-center space-x-1">
6
+
7
+ {{#if @onClose}}
8
+ <button
9
+ type="button"
10
+ class="tb-toolbar-btn flex items-center space-x-1 px-2 py-1.5 rounded text-xs font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors mr-1"
11
+ title={{or @closeLabel "Close"}}
12
+ {{on "click" this.close}}
13
+ >
14
+ <FaIcon @icon={{or @closeIcon "chevron-left"}} class="w-3.5 h-3.5" />
15
+ {{#if @closeLabel}}
16
+ <span class="hidden xl:inline">{{@closeLabel}}</span>
17
+ {{/if}}
18
+ </button>
19
+ <div class="w-px h-5 bg-gray-200 dark:bg-gray-700 mr-1"></div>
20
+ {{/if}}
21
+
22
+ {{#each this.elementTypes as |et|}}
23
+ <button
24
+ type="button"
25
+ class="tb-toolbar-btn flex items-center space-x-1 px-2 py-1.5 rounded text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
26
+ title="Add {{et.label}}"
27
+ {{on "click" (fn this.addElement et.type)}}
28
+ >
29
+ <FaIcon @icon={{et.icon}} class="w-3.5 h-3.5" />
30
+ <span class="hidden xl:inline">{{et.label}}</span>
31
+ </button>
32
+ {{/each}}
33
+ </div>
34
+
35
+ {{! Center: Zoom controls }}
36
+ <div class="flex items-center space-x-1">
37
+ <button
38
+ type="button"
39
+ class="tb-toolbar-btn p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
40
+ title="Zoom out"
41
+ {{on "click" this.zoomOut}}
42
+ >
43
+ <FaIcon @icon="magnifying-glass-minus" class="w-3.5 h-3.5" />
44
+ </button>
45
+ <button
46
+ type="button"
47
+ class="tb-toolbar-btn px-2 py-1 rounded text-xs font-mono text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-w-12 text-center"
48
+ title="Reset zoom"
49
+ {{on "click" this.zoomReset}}
50
+ >
51
+ {{this.zoomPercent}}
52
+ </button>
53
+ <button
54
+ type="button"
55
+ class="tb-toolbar-btn p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
56
+ title="Zoom in"
57
+ {{on "click" this.zoomIn}}
58
+ >
59
+ <FaIcon @icon="magnifying-glass-plus" class="w-3.5 h-3.5" />
60
+ </button>
61
+ </div>
62
+
63
+ {{! Right: Rotate + Undo/Redo + Preview + Save }}
64
+ <div class="flex items-center space-x-1">
65
+
66
+ {{! Rotate left/right — enabled only when an element is selected }}
67
+ <button
68
+ type="button"
69
+ class="tb-toolbar-btn p-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed
70
+ {{if @selectedElement
71
+ 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
72
+ 'text-gray-400 dark:text-gray-600'}}"
73
+ title="Rotate left 90°"
74
+ disabled={{not @selectedElement}}
75
+ {{on "click" this.rotateLeft}}
76
+ >
77
+ <FaIcon @icon="rotate-left" class="w-3.5 h-3.5" />
78
+ </button>
79
+ <button
80
+ type="button"
81
+ class="tb-toolbar-btn p-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed
82
+ {{if @selectedElement
83
+ 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
84
+ 'text-gray-400 dark:text-gray-600'}}"
85
+ title="Rotate right 90°"
86
+ disabled={{not @selectedElement}}
87
+ {{on "click" this.rotateRight}}
88
+ >
89
+ <FaIcon @icon="rotate-right" class="w-3.5 h-3.5" />
90
+ </button>
91
+
92
+ <div class="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"></div>
93
+
94
+ <button
95
+ type="button"
96
+ class="tb-toolbar-btn p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
97
+ title="Undo"
98
+ disabled={{not @canUndo}}
99
+ {{on "click" this.undo}}
100
+ >
101
+ <FaIcon @icon="step-backward" class="w-3.5 h-3.5" />
102
+ </button>
103
+ <button
104
+ type="button"
105
+ class="tb-toolbar-btn p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
106
+ title="Redo"
107
+ disabled={{not @canRedo}}
108
+ {{on "click" this.redo}}
109
+ >
110
+ <FaIcon @icon="step-forward" class="w-3.5 h-3.5" />
111
+ </button>
112
+
113
+ <div class="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"></div>
114
+
115
+ <button
116
+ type="button"
117
+ class="tb-toolbar-btn flex items-center space-x-1.5 px-3 py-1.5 rounded text-xs font-medium text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
118
+ title="Preview template"
119
+ {{on "click" this.preview}}
120
+ >
121
+ <FaIcon @icon="eye" class="w-3.5 h-3.5" />
122
+ <span>Preview</span>
123
+ </button>
124
+
125
+ <Button
126
+ @type="primary"
127
+ @icon="floppy-disk"
128
+ @text="Save"
129
+ @size="xs"
130
+ @isLoading={{@isSaving}}
131
+ @onClick={{this.save}}
132
+ />
133
+ </div>
134
+ </div>
@@ -0,0 +1,106 @@
1
+ import Component from '@glimmer/component';
2
+ import { action } from '@ember/object';
3
+
4
+ /**
5
+ * TemplateBuilderToolbarComponent
6
+ *
7
+ * Top toolbar for the template builder. Provides:
8
+ * - Element type buttons (add text, image, table, line, shape, QR, barcode)
9
+ * - Zoom controls
10
+ * - Undo / Redo
11
+ * - Preview and Save/Publish actions
12
+ *
13
+ * @argument {Number} zoom - Current zoom level (e.g. 1 = 100%)
14
+ * @argument {Object} selectedElement - Currently selected element (or null)
15
+ * @argument {Boolean} canUndo - Whether undo is available
16
+ * @argument {Boolean} canRedo - Whether redo is available
17
+ * @argument {Boolean} isSaving - Whether a save is in progress
18
+ * @argument {Function} onAddElement - Called with element type string
19
+ * @argument {Function} onZoomIn - Called when zoom in is clicked
20
+ * @argument {Function} onZoomOut - Called when zoom out is clicked
21
+ * @argument {Function} onZoomReset - Called when zoom reset is clicked
22
+ * @argument {Function} onUndo - Called when undo is clicked
23
+ * @argument {Function} onRedo - Called when redo is clicked
24
+ * @argument {Function} onPreview - Called when preview is clicked
25
+ * @argument {Function} onSave - Called when save is clicked
26
+ * @argument {Function} onRotateElement - Called with (uuid, deltaDegrees)
27
+ * @argument {Function} [onClose] - Optional. If provided, a close/back button is rendered on the left.
28
+ * @argument {String} [closeIcon] - FontAwesome icon name for the close button (default: 'chevron-left')
29
+ * @argument {String} [closeLabel] - Text label shown beside the close icon (default: none)
30
+ */
31
+ export default class TemplateBuilderToolbarComponent extends Component {
32
+ elementTypes = [
33
+ { type: 'text', icon: 'font', label: 'Text' },
34
+ { type: 'image', icon: 'image', label: 'Image' },
35
+ { type: 'table', icon: 'table', label: 'Table' },
36
+ { type: 'line', icon: 'minus', label: 'Line' },
37
+ { type: 'shape', icon: 'square', label: 'Shape' },
38
+ { type: 'qr_code', icon: 'qrcode', label: 'QR Code' },
39
+ { type: 'barcode', icon: 'barcode', label: 'Barcode' },
40
+ ];
41
+
42
+ get zoomPercent() {
43
+ return `${Math.round((this.args.zoom ?? 1) * 100)}%`;
44
+ }
45
+
46
+ @action
47
+ addElement(type) {
48
+ if (this.args.onAddElement) {
49
+ this.args.onAddElement(type);
50
+ }
51
+ }
52
+
53
+ @action
54
+ zoomIn() {
55
+ if (this.args.onZoomIn) this.args.onZoomIn();
56
+ }
57
+
58
+ @action
59
+ zoomOut() {
60
+ if (this.args.onZoomOut) this.args.onZoomOut();
61
+ }
62
+
63
+ @action
64
+ zoomReset() {
65
+ if (this.args.onZoomReset) this.args.onZoomReset();
66
+ }
67
+
68
+ @action
69
+ undo() {
70
+ if (this.args.onUndo) this.args.onUndo();
71
+ }
72
+
73
+ @action
74
+ redo() {
75
+ if (this.args.onRedo) this.args.onRedo();
76
+ }
77
+
78
+ @action
79
+ preview() {
80
+ if (this.args.onPreview) this.args.onPreview();
81
+ }
82
+
83
+ @action
84
+ save() {
85
+ if (this.args.onSave) this.args.onSave();
86
+ }
87
+
88
+ @action
89
+ close() {
90
+ if (this.args.onClose) this.args.onClose();
91
+ }
92
+
93
+ @action
94
+ rotateLeft() {
95
+ if (this.args.onRotateElement && this.args.selectedElement) {
96
+ this.args.onRotateElement(this.args.selectedElement.uuid, -90);
97
+ }
98
+ }
99
+
100
+ @action
101
+ rotateRight() {
102
+ if (this.args.onRotateElement && this.args.selectedElement) {
103
+ this.args.onRotateElement(this.args.selectedElement.uuid, 90);
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,210 @@
1
+ {{! Template Builder Variable Picker }}
2
+ {{#if @isOpen}}
3
+ <div class="tb-variable-picker fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true">
4
+ {{! Backdrop }}
5
+ <div class="absolute inset-0 bg-black/40 dark:bg-black/60" {{on "click" this.close}}></div>
6
+
7
+ {{! Modal }}
8
+ <div class="relative z-10 w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col max-h-[80vh]">
9
+
10
+ {{! Header }}
11
+ <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
12
+ <div class="flex items-center space-x-2">
13
+ <FaIcon @icon="code" class="w-4 h-4 text-blue-500" />
14
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white">Insert Variable or Formula</h3>
15
+ </div>
16
+ <button type="button" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400" {{on "click" this.close}}>
17
+ <FaIcon @icon="xmark" class="w-4 h-4" />
18
+ </button>
19
+ </div>
20
+
21
+ {{! Tabs }}
22
+ <div class="flex border-b border-gray-200 dark:border-gray-700">
23
+ <button
24
+ type="button"
25
+ class="flex-1 px-4 py-2 text-xs font-medium border-b-2 transition-colors {{if (eq this.activeTab 'variables') 'border-blue-500 text-blue-600 dark:text-blue-400' 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}}"
26
+ {{on "click" (fn this.switchTab "variables")}}
27
+ >
28
+ <FaIcon @icon="brackets-curly" class="w-3 h-3 mr-1.5 inline" />
29
+ Variables
30
+ </button>
31
+ <button
32
+ type="button"
33
+ class="flex-1 px-4 py-2 text-xs font-medium border-b-2 transition-colors {{if (eq this.activeTab 'formula') 'border-blue-500 text-blue-600 dark:text-blue-400' 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}}"
34
+ {{on "click" (fn this.switchTab "formula")}}
35
+ >
36
+ <FaIcon @icon="calculator" class="w-3 h-3 mr-1.5 inline" />
37
+ Formula
38
+ </button>
39
+ </div>
40
+
41
+ {{! VARIABLES TAB }}
42
+ {{#if (eq this.activeTab "variables")}}
43
+ {{! Search }}
44
+ <div class="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
45
+ <div class="relative">
46
+ <FaIcon @icon="magnifying-glass" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400" />
47
+ <input
48
+ type="text"
49
+ class="w-full pl-7 pr-3 py-1.5 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md outline-none focus:border-blue-400 dark:focus:border-blue-500"
50
+ placeholder="Search variables..."
51
+ value={{this.searchQuery}}
52
+ {{on "input" this.updateSearch}}
53
+ />
54
+ </div>
55
+ </div>
56
+
57
+ {{! Variable list }}
58
+ <div class="flex-1 overflow-y-auto">
59
+ {{! Global variables section }}
60
+ <div class="border-b border-gray-100 dark:border-gray-800">
61
+ <button
62
+ type="button"
63
+ class="w-full flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
64
+ {{on "click" (fn this.toggleNamespace "__global__")}}
65
+ >
66
+ <div class="flex items-center space-x-2">
67
+ <FaIcon @icon="globe" class="w-3.5 h-3.5 text-purple-500" />
68
+ <span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Global</span>
69
+ <span class="text-xs text-gray-400 dark:text-gray-500">Always available</span>
70
+ </div>
71
+ <FaIcon @icon={{if (this.isExpanded "__global__") "chevron-up" "chevron-down"}} class="w-3 h-3 text-gray-400" />
72
+ </button>
73
+ {{#if (this.isExpanded "__global__")}}
74
+ {{#each this.globalVariables as |variable|}}
75
+ <button
76
+ type="button"
77
+ class="w-full flex items-center px-4 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20 group transition-colors"
78
+ {{on "click" (fn this.insertVariable variable)}}
79
+ >
80
+ <FaIcon @icon={{this.typeIcon variable.type}} class="w-3 h-3 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
81
+ <div class="flex-1 min-w-0 text-left">
82
+ <span class="block text-xs text-gray-700 dark:text-gray-300 font-mono">{{variable.path}}</span>
83
+ <span class="block text-xs text-gray-400 dark:text-gray-500 truncate">{{variable.label}}</span>
84
+ </div>
85
+ <span class="text-xs text-gray-300 dark:text-gray-600 font-mono truncate max-w-24 ml-2 hidden group-hover:block">{{variable.example}}</span>
86
+ </button>
87
+ {{/each}}
88
+ {{/if}}
89
+ </div>
90
+
91
+ {{! Context schema sections }}
92
+ {{#if this.hasResults}}
93
+ {{#each this.filteredSchemas as |schema|}}
94
+ <div class="border-b border-gray-100 dark:border-gray-800">
95
+ <button
96
+ type="button"
97
+ class="w-full flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
98
+ {{on "click" (fn this.toggleNamespace schema.namespace)}}
99
+ >
100
+ <div class="flex items-center space-x-2">
101
+ <FaIcon @icon={{schema.icon}} class="w-3.5 h-3.5 text-blue-500" />
102
+ <span class="text-xs font-semibold text-gray-700 dark:text-gray-300 capitalize">{{schema.label}}</span>
103
+ <span class="text-xs text-gray-400 dark:text-gray-500">{{schema.variables.length}} variables</span>
104
+ </div>
105
+ <FaIcon @icon={{if (this.isExpanded schema.namespace) "chevron-up" "chevron-down"}} class="w-3 h-3 text-gray-400" />
106
+ </button>
107
+ {{#if (this.isExpanded schema.namespace)}}
108
+ {{#each schema.variables as |variable|}}
109
+ <button
110
+ type="button"
111
+ class="w-full flex items-center px-4 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20 group transition-colors"
112
+ {{on "click" (fn this.insertVariable variable)}}
113
+ >
114
+ <FaIcon @icon={{this.typeIcon variable.type}} class="w-3 h-3 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
115
+ <div class="flex-1 min-w-0 text-left">
116
+ <span class="block text-xs text-gray-700 dark:text-gray-300 font-mono">{{variable.path}}</span>
117
+ <span class="block text-xs text-gray-400 dark:text-gray-500 truncate">{{variable.label}}</span>
118
+ </div>
119
+ <span class="text-xs text-gray-300 dark:text-gray-600 font-mono truncate max-w-24 ml-2 hidden group-hover:block">{{variable.example}}</span>
120
+ </button>
121
+ {{/each}}
122
+ {{/if}}
123
+ </div>
124
+ {{/each}}
125
+ {{else}}
126
+ <div class="flex flex-col items-center justify-center py-10 text-center">
127
+ <FaIcon @icon="magnifying-glass" class="w-6 h-6 text-gray-300 dark:text-gray-600 mb-2" />
128
+ <p class="text-xs text-gray-400 dark:text-gray-500">No variables match "{{this.searchQuery}}"</p>
129
+ </div>
130
+ {{/if}}
131
+ </div>
132
+ {{/if}}
133
+
134
+ {{! FORMULA TAB }}
135
+ {{#if (eq this.activeTab "formula")}}
136
+ <div class="flex-1 overflow-y-auto p-4 space-y-4">
137
+ {{! Syntax guide }}
138
+ <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
139
+ <p class="text-xs font-semibold text-blue-700 dark:text-blue-300 mb-1">Formula Syntax</p>
140
+ <p class="text-xs text-blue-600 dark:text-blue-400 font-mono">[{ expression }]</p>
141
+ <p class="text-xs text-blue-500 dark:text-blue-400 mt-1">Use variables inside the expression:</p>
142
+ <p class="text-xs text-blue-600 dark:text-blue-400 font-mono mt-0.5">[{ {invoice.subtotal} * 1.1 }]</p>
143
+ <p class="text-xs text-blue-600 dark:text-blue-400 font-mono">[{ {order.quantity} * {order.unit_price} }]</p>
144
+ <p class="text-xs text-blue-500 dark:text-blue-400 mt-1">Supported: + − × ÷ ( ) round() abs() ceil() floor()</p>
145
+ </div>
146
+
147
+ {{! Formula input }}
148
+ <div>
149
+ <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Expression</label>
150
+ <textarea
151
+ class="w-full font-mono text-xs bg-gray-50 dark:bg-gray-800 border {{if this.formulaError 'border-red-400' 'border-gray-200 dark:border-gray-700'}} rounded-lg px-3 py-2 outline-none focus:border-blue-400 dark:focus:border-blue-500 resize-none min-h-20"
152
+ placeholder="{invoice.subtotal} * 1.1"
153
+ {{on "input" this.updateFormula}}
154
+ >{{this.formulaExpression}}</textarea>
155
+ {{#if this.formulaError}}
156
+ <p class="text-xs text-red-500 mt-1">{{this.formulaError}}</p>
157
+ {{/if}}
158
+ </div>
159
+
160
+ {{! Preview }}
161
+ {{#if this.formulaPreview}}
162
+ <div>
163
+ <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Preview</label>
164
+ <div class="font-mono text-xs bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg px-3 py-2 break-all">
165
+ {{this.formulaPreview}}
166
+ </div>
167
+ </div>
168
+ {{/if}}
169
+
170
+ {{! Quick-insert variables into formula }}
171
+ <div>
172
+ <p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Quick-insert variable</p>
173
+ <div class="space-y-1 max-h-40 overflow-y-auto">
174
+ {{#each this.contextSchemas as |schema|}}
175
+ {{#each schema.variables as |variable|}}
176
+ {{#if (eq variable.type "number")}}
177
+ <button
178
+ type="button"
179
+ class="w-full flex items-center px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-left transition-colors"
180
+ {{on "click" (fn this.insertVariableIntoFormula variable)}}
181
+ >
182
+ <FaIcon @icon="hashtag" class="w-3 h-3 mr-2 text-gray-400 flex-shrink-0" />
183
+ <span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{variable.path}}</span>
184
+ <span class="text-xs text-gray-400 dark:text-gray-500 ml-2 truncate">{{variable.label}}</span>
185
+ </button>
186
+ {{/if}}
187
+ {{/each}}
188
+ {{/each}}
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ {{! Footer }}
194
+ <div class="flex items-center justify-end space-x-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
195
+ <button type="button" class="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors" {{on "click" this.close}}>
196
+ Cancel
197
+ </button>
198
+ <Button
199
+ @type="primary"
200
+ @text="Insert Formula"
201
+ @icon="calculator"
202
+ @size="xs"
203
+ @isDisabled={{not this.formulaExpression}}
204
+ @onClick={{this.insertFormula}}
205
+ />
206
+ </div>
207
+ {{/if}}
208
+ </div>
209
+ </div>
210
+ {{/if}}
@@ -0,0 +1,181 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+
5
+ /**
6
+ * TemplateBuilderVariablePickerComponent
7
+ *
8
+ * A modal/panel for browsing available variables from the context schema
9
+ * and inserting them — or a formula using the [{...}] syntax — into the
10
+ * currently targeted element property.
11
+ *
12
+ * The component is split into two tabs:
13
+ * 1. Variables — browse the context schema tree and click to insert
14
+ * 2. Formula — compose a formula expression with variable autocomplete
15
+ *
16
+ * @argument {Array} contextSchemas - Array of context schema objects from the API
17
+ * Each schema: { namespace, label, icon, variables: [{path, label, type, example}] }
18
+ * @argument {Boolean} isOpen - Whether the picker is visible
19
+ * @argument {Function} onInsert - Called with the final string to insert (e.g. '{invoice.total}' or '[{ {a} + {b} }]')
20
+ * @argument {Function} onClose - Called when the picker should be dismissed
21
+ */
22
+ export default class TemplateBuilderVariablePickerComponent extends Component {
23
+ @tracked activeTab = 'variables';
24
+ @tracked searchQuery = '';
25
+ @tracked expandedNamespaces = new Set();
26
+ @tracked formulaExpression = '';
27
+ @tracked formulaError = null;
28
+
29
+ // -------------------------------------------------------------------------
30
+ // Computed
31
+ // -------------------------------------------------------------------------
32
+
33
+ get contextSchemas() {
34
+ const raw = this.args.contextSchemas;
35
+ // Guard: the API may return an object keyed by namespace instead of an
36
+ // array. The routes normalise this, but be defensive here too.
37
+ if (Array.isArray(raw)) return raw;
38
+ return [];
39
+ }
40
+
41
+ get filteredSchemas() {
42
+ const q = this.searchQuery.toLowerCase().trim();
43
+ if (!q) return this.contextSchemas;
44
+
45
+ return this.contextSchemas
46
+ .map((schema) => ({
47
+ ...schema,
48
+ variables: (schema.variables ?? []).filter((v) => v.path.toLowerCase().includes(q) || (v.label ?? '').toLowerCase().includes(q)),
49
+ }))
50
+ .filter((schema) => schema.variables.length > 0);
51
+ }
52
+
53
+ get hasResults() {
54
+ return this.filteredSchemas.some((s) => (s.variables ?? []).length > 0);
55
+ }
56
+
57
+ get formulaPreview() {
58
+ if (!this.formulaExpression.trim()) return '';
59
+ return `[{ ${this.formulaExpression.trim()} }]`;
60
+ }
61
+
62
+ @action
63
+ isExpanded(namespace) {
64
+ return this.expandedNamespaces.has(namespace);
65
+ }
66
+
67
+ // -------------------------------------------------------------------------
68
+ // Actions
69
+ // -------------------------------------------------------------------------
70
+
71
+ @action
72
+ switchTab(tab) {
73
+ this.activeTab = tab;
74
+ }
75
+
76
+ @action
77
+ updateSearch(event) {
78
+ this.searchQuery = event.target.value;
79
+ // Auto-expand all namespaces when searching
80
+ if (this.searchQuery) {
81
+ this.expandedNamespaces = new Set(this.contextSchemas.map((s) => s.namespace));
82
+ }
83
+ }
84
+
85
+ @action
86
+ toggleNamespace(namespace) {
87
+ const next = new Set(this.expandedNamespaces);
88
+ if (next.has(namespace)) {
89
+ next.delete(namespace);
90
+ } else {
91
+ next.add(namespace);
92
+ }
93
+ this.expandedNamespaces = next;
94
+ }
95
+
96
+ @action
97
+ insertVariable(variable) {
98
+ const token = `{${variable.path}}`;
99
+ if (this.args.onInsert) {
100
+ this.args.onInsert(token);
101
+ }
102
+ this.close();
103
+ }
104
+
105
+ @action
106
+ insertVariableIntoFormula(variable) {
107
+ this.formulaExpression = `${this.formulaExpression}{${variable.path}}`;
108
+ }
109
+
110
+ @action
111
+ updateFormula(event) {
112
+ this.formulaExpression = event.target.value;
113
+ this.formulaError = null;
114
+ }
115
+
116
+ @action
117
+ insertFormula() {
118
+ if (!this.formulaExpression.trim()) return;
119
+
120
+ // Basic client-side validation: check balanced braces
121
+ const expr = this.formulaExpression;
122
+ const openBraces = (expr.match(/\{/g) ?? []).length;
123
+ const closeBraces = (expr.match(/\}/g) ?? []).length;
124
+ if (openBraces !== closeBraces) {
125
+ this.formulaError = 'Unbalanced curly braces in formula.';
126
+ return;
127
+ }
128
+
129
+ const token = `[{ ${expr.trim()} }]`;
130
+ if (this.args.onInsert) {
131
+ this.args.onInsert(token);
132
+ }
133
+ this.close();
134
+ }
135
+
136
+ @action
137
+ close() {
138
+ this.searchQuery = '';
139
+ this.formulaExpression = '';
140
+ this.formulaError = null;
141
+ this.activeTab = 'variables';
142
+ if (this.args.onClose) {
143
+ this.args.onClose();
144
+ }
145
+ }
146
+
147
+ // -------------------------------------------------------------------------
148
+ // Global variable shortcuts
149
+ // -------------------------------------------------------------------------
150
+
151
+ get globalVariables() {
152
+ return [
153
+ { path: 'company.name', label: 'Company Name', type: 'string', example: 'Acme Logistics' },
154
+ { path: 'company.email', label: 'Company Email', type: 'string', example: 'hello@acme.com' },
155
+ { path: 'company.phone', label: 'Company Phone', type: 'string', example: '+1 555 0100' },
156
+ { path: 'company.address', label: 'Company Address', type: 'string', example: '123 Main St' },
157
+ { path: 'company.logo', label: 'Company Logo URL', type: 'url', example: 'https://...' },
158
+ { path: 'user.name', label: 'Current User', type: 'string', example: 'John Doe' },
159
+ { path: 'user.email', label: 'User Email', type: 'string', example: 'john@acme.com' },
160
+ { path: 'now', label: 'Current DateTime', type: 'datetime', example: '2025-03-03 14:00' },
161
+ { path: 'today', label: "Today's Date", type: 'date', example: '2025-03-03' },
162
+ { path: 'year', label: 'Current Year', type: 'number', example: '2025' },
163
+ ];
164
+ }
165
+
166
+ get typeIcon() {
167
+ return (type) => {
168
+ const map = {
169
+ string: 'font',
170
+ number: 'hashtag',
171
+ date: 'calendar',
172
+ datetime: 'clock',
173
+ url: 'link',
174
+ boolean: 'toggle-on',
175
+ array: 'list',
176
+ object: 'cube',
177
+ };
178
+ return map[type] ?? 'circle-dot';
179
+ };
180
+ }
181
+ }