@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.
- package/addon/components/layout/header/smart-nav-menu/dropdown.hbs +77 -68
- package/addon/components/layout/header/smart-nav-menu/dropdown.js +7 -54
- package/addon/components/template-builder/canvas.hbs +23 -0
- package/addon/components/template-builder/canvas.js +116 -0
- package/addon/components/template-builder/element-renderer.hbs +126 -0
- package/addon/components/template-builder/element-renderer.js +398 -0
- package/addon/components/template-builder/layers-panel.hbs +99 -0
- package/addon/components/template-builder/layers-panel.js +146 -0
- package/addon/components/template-builder/properties-panel/field.hbs +7 -0
- package/addon/components/template-builder/properties-panel/field.js +9 -0
- package/addon/components/template-builder/properties-panel/section.hbs +24 -0
- package/addon/components/template-builder/properties-panel/section.js +19 -0
- package/addon/components/template-builder/properties-panel.hbs +576 -0
- package/addon/components/template-builder/properties-panel.js +413 -0
- package/addon/components/template-builder/queries-panel.hbs +84 -0
- package/addon/components/template-builder/queries-panel.js +88 -0
- package/addon/components/template-builder/query-form.hbs +260 -0
- package/addon/components/template-builder/query-form.js +309 -0
- package/addon/components/template-builder/toolbar.hbs +134 -0
- package/addon/components/template-builder/toolbar.js +106 -0
- package/addon/components/template-builder/variable-picker.hbs +210 -0
- package/addon/components/template-builder/variable-picker.js +181 -0
- package/addon/components/template-builder.hbs +119 -0
- package/addon/components/template-builder.js +567 -0
- package/addon/helpers/string-starts-with.js +14 -0
- package/addon/services/template-builder.js +72 -0
- package/addon/styles/addon.css +1 -0
- package/addon/styles/components/badge.css +66 -12
- package/addon/styles/components/smart-nav-menu.css +35 -29
- package/addon/styles/components/template-builder.css +297 -0
- package/addon/utils/get-currency.js +1 -1
- package/app/components/template-builder/canvas.js +1 -0
- package/app/components/template-builder/element-renderer.js +1 -0
- package/app/components/template-builder/layers-panel.js +1 -0
- package/app/components/template-builder/properties-panel/field.js +1 -0
- package/app/components/template-builder/properties-panel/section.js +1 -0
- package/app/components/template-builder/properties-panel.js +1 -0
- package/app/components/template-builder/queries-panel.js +1 -0
- package/app/components/template-builder/query-form.js +1 -0
- package/app/components/template-builder/toolbar.js +1 -0
- package/app/components/template-builder/variable-picker.js +1 -0
- package/app/components/template-builder.js +1 -0
- package/app/helpers/string-starts-with.js +1 -0
- package/app/services/template-builder.js +1 -0
- package/package.json +3 -2
- 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
|
+
}
|