@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,260 @@
1
+ {{! Template Builder Query Form Modal }}
2
+ {{! Consume _syncKey so Glimmer tracks @isOpen and @query changes }}
3
+ {{this._syncKey}}
4
+ {{#if @isOpen}}
5
+ <div class="tb-query-form fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true">
6
+
7
+ {{! Backdrop }}
8
+ <div class="absolute inset-0 bg-black/40 dark:bg-black/60" {{on "click" this.cancel}}></div>
9
+
10
+ {{! Modal }}
11
+ <div class="relative z-10 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col max-h-[90vh]">
12
+
13
+ {{! Header }}
14
+ <div class="flex items-center justify-between px-5 py-3.5 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
15
+ <div class="flex items-center space-x-2">
16
+ <FaIcon @icon="database" class="w-4 h-4 text-indigo-500" />
17
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
18
+ {{if this.isEditing "Edit Query" "New Query"}}
19
+ </h3>
20
+ </div>
21
+ <button type="button" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400" {{on "click" this.cancel}}>
22
+ <FaIcon @icon="xmark" class="w-4 h-4" />
23
+ </button>
24
+ </div>
25
+
26
+ {{! Body (scrollable) }}
27
+ <div class="flex-1 overflow-y-auto px-5 py-4 space-y-6">
28
+
29
+ {{! ── Basic Info ── }}
30
+ <div>
31
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Basic Info</h4>
32
+
33
+ <div class="grid grid-cols-2 gap-3">
34
+ <div>
35
+ <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
36
+ Label <span class="text-red-400">*</span>
37
+ </label>
38
+ <input
39
+ type="text"
40
+ class="tb-input w-full"
41
+ placeholder="e.g. Recent Orders"
42
+ value={{this.label}}
43
+ {{on "input" this.updateLabel}}
44
+ />
45
+ </div>
46
+ <div>
47
+ <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Variable Name</label>
48
+ <div class="flex items-center space-x-1">
49
+ <span class="text-xs text-gray-400 font-mono">{</span>
50
+ <input
51
+ type="text"
52
+ class="tb-input flex-1 font-mono"
53
+ placeholder="recent_orders"
54
+ value={{this.variableName}}
55
+ {{on "input" this.updateVariableName}}
56
+ />
57
+ <span class="text-xs text-gray-400 font-mono">}</span>
58
+ </div>
59
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Auto-derived from label if left blank.</p>
60
+ </div>
61
+ </div>
62
+
63
+ <div>
64
+ <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
65
+ <input
66
+ type="text"
67
+ class="tb-input w-full"
68
+ placeholder="Optional description"
69
+ value={{this.description}}
70
+ {{on "input" this.updateDescription}}
71
+ />
72
+ </div>
73
+ </div>
74
+
75
+ {{! ── Resource Type ── }}
76
+ <div>
77
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Resource Type</h4>
78
+ <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Select the Fleetbase model this query runs against.</p>
79
+
80
+ <div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-2">
81
+ {{#each this.resourceTypes as |rt|}}
82
+ <button
83
+ type="button"
84
+ class="flex items-center space-x-2 px-2.5 py-2 rounded-lg border text-left transition-colors
85
+ {{if (eq this.modelType rt.value)
86
+ 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300'
87
+ 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 text-gray-700 dark:text-gray-300'}}"
88
+ {{on "click" (fn this.selectResourceType rt.value)}}
89
+ >
90
+ <FaIcon @icon={{rt.icon}} class="w-3.5 h-3.5 flex-shrink-0 {{if (eq this.modelType rt.value) 'text-indigo-500' 'text-gray-400'}}" />
91
+ <span class="block text-xs font-medium truncate">{{rt.label}}</span>
92
+ </button>
93
+ {{/each}}
94
+ </div>
95
+ </div>
96
+
97
+ {{! ── Filter Conditions ── }}
98
+ <div>
99
+ <div class="flex items-center justify-between mb-2">
100
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter Conditions</h4>
101
+ <button
102
+ type="button"
103
+ class="flex items-center space-x-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700"
104
+ {{on "click" this.addCondition}}
105
+ >
106
+ <FaIcon @icon="plus" class="w-3 h-3" />
107
+ <span>Add condition</span>
108
+ </button>
109
+ </div>
110
+
111
+ {{#if this.conditions.length}}
112
+ <div class="space-y-1.5">
113
+ {{#each this.conditions as |condition condIndex|}}
114
+ {{! Each row: field (flex-1) | operator (fixed 130px) | value (flex-1) | remove }}
115
+ <div class="flex items-center gap-1.5">
116
+ <input
117
+ type="text"
118
+ class="tb-input min-w-0 flex-1"
119
+ placeholder="field"
120
+ value={{condition.field}}
121
+ {{on "input" (fn this.updateConditionField condIndex "field")}}
122
+ />
123
+ <select
124
+ class="tb-input flex-shrink-0 tb-select-operator"
125
+ {{on "change" (fn this.updateConditionField condIndex "operator")}}
126
+ >
127
+ {{#each this.conditionOperators as |op|}}
128
+ <option value={{op.value}} selected={{eq condition.operator op.value}}>{{op.label}}</option>
129
+ {{/each}}
130
+ </select>
131
+ {{#if (this.showConditionValue condition.operator)}}
132
+ <input
133
+ type="text"
134
+ class="tb-input min-w-0 flex-1"
135
+ placeholder="value or {variable}"
136
+ value={{condition.value}}
137
+ {{on "input" (fn this.updateConditionField condIndex "value")}}
138
+ />
139
+ {{/if}}
140
+ <button
141
+ type="button"
142
+ class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 flex-shrink-0"
143
+ title="Remove condition"
144
+ {{on "click" (fn this.removeCondition condIndex)}}
145
+ >
146
+ <FaIcon @icon="xmark" class="w-3 h-3" />
147
+ </button>
148
+ </div>
149
+ {{/each}}
150
+ </div>
151
+ {{else}}
152
+ <p class="text-xs text-gray-400 dark:text-gray-500 text-center py-2 border border-dashed border-gray-200 dark:border-gray-700 rounded-lg">No conditions — all records will be returned.</p>
153
+ {{/if}}
154
+ </div>
155
+
156
+ {{! ── Sort ── }}
157
+ <div>
158
+ <div class="flex items-center justify-between mb-2">
159
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sort</h4>
160
+ <button
161
+ type="button"
162
+ class="flex items-center space-x-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700"
163
+ {{on "click" this.addSort}}
164
+ >
165
+ <FaIcon @icon="plus" class="w-3 h-3" />
166
+ <span>Add sort</span>
167
+ </button>
168
+ </div>
169
+
170
+ {{#if this.sort.length}}
171
+ <div class="space-y-1.5">
172
+ {{#each this.sort as |sortItem sortIndex|}}
173
+ <div class="flex items-center gap-1.5">
174
+ <input
175
+ type="text"
176
+ class="tb-input min-w-0 flex-1"
177
+ placeholder="field (e.g. created_at)"
178
+ value={{sortItem.field}}
179
+ {{on "input" (fn this.updateSortField sortIndex "field")}}
180
+ />
181
+ <select
182
+ class="tb-input flex-shrink-0 tb-select-direction"
183
+ {{on "change" (fn this.updateSortField sortIndex "direction")}}
184
+ >
185
+ <option value="asc" selected={{eq sortItem.direction "asc"}}>Ascending</option>
186
+ <option value="desc" selected={{eq sortItem.direction "desc"}}>Descending</option>
187
+ </select>
188
+ <button
189
+ type="button"
190
+ class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 flex-shrink-0"
191
+ title="Remove sort"
192
+ {{on "click" (fn this.removeSort sortIndex)}}
193
+ >
194
+ <FaIcon @icon="xmark" class="w-3 h-3" />
195
+ </button>
196
+ </div>
197
+ {{/each}}
198
+ </div>
199
+ {{else}}
200
+ <p class="text-xs text-gray-400 dark:text-gray-500 text-center py-2 border border-dashed border-gray-200 dark:border-gray-700 rounded-lg">No sort — default model order will be used.</p>
201
+ {{/if}}
202
+ </div>
203
+
204
+ {{! ── Limit & Eager-load ── }}
205
+ <div class="grid grid-cols-2 gap-4">
206
+ <div>
207
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Limit</h4>
208
+ <input
209
+ type="number"
210
+ class="tb-input w-full"
211
+ placeholder="No limit"
212
+ min="1"
213
+ value={{this.limit}}
214
+ {{on "input" this.updateLimit}}
215
+ />
216
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Max number of rows to return.</p>
217
+ </div>
218
+ <div>
219
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Eager Load (with)</h4>
220
+ <input
221
+ type="text"
222
+ class="tb-input w-full"
223
+ placeholder="e.g. driver,vehicle"
224
+ value={{this.withString}}
225
+ {{on "input" this.updateWith}}
226
+ />
227
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Comma-separated relationship names to eager-load.</p>
228
+ </div>
229
+ </div>
230
+
231
+ </div>
232
+
233
+ {{! Footer }}
234
+ <div class="flex items-center justify-between px-5 py-3.5 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
235
+ {{#if this.errorMessage}}
236
+ <p class="text-xs text-red-500">{{this.errorMessage}}</p>
237
+ {{else}}
238
+ <div></div>
239
+ {{/if}}
240
+ <div class="flex items-center space-x-2">
241
+ <button
242
+ type="button"
243
+ 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"
244
+ {{on "click" this.cancel}}
245
+ >
246
+ Cancel
247
+ </button>
248
+ <Button
249
+ @type="primary"
250
+ @text={{if this.isEditing "Save Changes" "Create Query"}}
251
+ @icon="floppy-disk"
252
+ @size="xs"
253
+ @onClick={{this.save}}
254
+ />
255
+ </div>
256
+ </div>
257
+
258
+ </div>
259
+ </div>
260
+ {{/if}}
@@ -0,0 +1,309 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { inject as service } from '@ember/service';
5
+
6
+ /**
7
+ * TemplateBuilderQueryFormComponent
8
+ *
9
+ * Modal dialog for creating or editing a TemplateQuery.
10
+ * This component is purely presentational — it validates the form and returns
11
+ * the data to the parent via @onSave. No API calls are made here; the parent
12
+ * (template-builder) persists everything in one go when the template is saved.
13
+ *
14
+ * Form state is split into individual @tracked fields plus separate @tracked
15
+ * arrays for conditions and sort. This prevents the {{#each}} loops from
16
+ * destroying and recreating input DOM elements on every keystroke (which would
17
+ * steal focus from the active input).
18
+ *
19
+ * @argument {Boolean} isOpen - Whether the modal is visible
20
+ * @argument {Object} query - Existing query to edit (null for create)
21
+ * @argument {Array} resourceTypes - Optional override for the resource type list
22
+ * @argument {Function} onSave - Called with the validated query data object
23
+ * @argument {Function} onClose - Called when the modal should close
24
+ */
25
+ export default class TemplateBuilderQueryFormComponent extends Component {
26
+ @service('template-builder') templateBuilderService;
27
+
28
+ // ── Scalar form fields ──────────────────────────────────────────────────
29
+ @tracked label = '';
30
+ @tracked variableName = '';
31
+ @tracked description = '';
32
+ @tracked modelType = '';
33
+ @tracked limit = '';
34
+ @tracked withRelations = [];
35
+
36
+ // ── Array fields — kept as separate tracked properties so that mutating
37
+ // a single item does NOT cause the entire form to re-render and destroy
38
+ // focused input elements. ────────────────────────────────────────────
39
+ @tracked conditions = [];
40
+ @tracked sort = [];
41
+
42
+ @tracked errorMessage = null;
43
+
44
+ /**
45
+ * Track whether the user has manually edited the variable_name field.
46
+ * While false, variable_name is auto-derived from label on every keystroke.
47
+ */
48
+ @tracked _variableNameManuallyEdited = false;
49
+
50
+ // -------------------------------------------------------------------------
51
+ // Lifecycle — sync form state when @query or @isOpen changes
52
+ // -------------------------------------------------------------------------
53
+
54
+ get isEditing() {
55
+ return !!this.args.query?.uuid;
56
+ }
57
+
58
+ // Glimmer re-evaluates this getter whenever @query or @isOpen changes,
59
+ // giving us a hook to reset the form without needing did-update modifiers.
60
+ get _syncKey() {
61
+ const key = `${this.args.isOpen}-${this.args.query?.uuid ?? 'new'}`;
62
+ this._syncForm();
63
+ return key;
64
+ }
65
+
66
+ _syncForm() {
67
+ const q = this.args.query;
68
+ if (q) {
69
+ this.label = q.label ?? '';
70
+ this.variableName = q.variable_name ?? '';
71
+ this.description = q.description ?? '';
72
+ this.modelType = q.model_type ?? '';
73
+ this.limit = q.limit ?? '';
74
+ this.withRelations = Array.isArray(q.with) ? [...q.with] : [];
75
+ this.conditions = JSON.parse(JSON.stringify(q.conditions ?? []));
76
+ this.sort = JSON.parse(JSON.stringify(q.sort ?? []));
77
+ this._variableNameManuallyEdited = !!q.variable_name?.trim();
78
+ } else {
79
+ this.label = '';
80
+ this.variableName = '';
81
+ this.description = '';
82
+ this.modelType = '';
83
+ this.limit = '';
84
+ this.withRelations = [];
85
+ this.conditions = [];
86
+ this.sort = [];
87
+ this._variableNameManuallyEdited = false;
88
+ }
89
+ this.errorMessage = null;
90
+ }
91
+
92
+ // -------------------------------------------------------------------------
93
+ // Resource types
94
+ // Priority: @resourceTypes arg > service-registered types > built-in defaults
95
+ // -------------------------------------------------------------------------
96
+
97
+ get resourceTypes() {
98
+ if (this.args.resourceTypes?.length) {
99
+ return this.args.resourceTypes;
100
+ }
101
+ const serviceTypes = this.templateBuilderService?.resourceTypes ?? [];
102
+ if (serviceTypes.length) {
103
+ return serviceTypes;
104
+ }
105
+ return [
106
+ { value: 'Fleetbase\\FleetOps\\Models\\Order', label: 'Order', icon: 'box' },
107
+ { value: 'Fleetbase\\FleetOps\\Models\\Driver', label: 'Driver', icon: 'id-card' },
108
+ { value: 'Fleetbase\\FleetOps\\Models\\Place', label: 'Place', icon: 'location-dot' },
109
+ { value: 'Fleetbase\\FleetOps\\Models\\Vendor', label: 'Vendor', icon: 'building' },
110
+ { value: 'Fleetbase\\FleetOps\\Models\\Contact', label: 'Contact', icon: 'address-book' },
111
+ { value: 'Fleetbase\\FleetOps\\Models\\Issue', label: 'Issue', icon: 'triangle-exclamation' },
112
+ { value: 'Fleetbase\\FleetOps\\Models\\Vehicle', label: 'Vehicle', icon: 'truck' },
113
+ { value: 'Fleetbase\\FleetOps\\Models\\FuelReport', label: 'Fuel Report', icon: 'gas-pump' },
114
+ { value: 'Fleetbase\\FleetOps\\Models\\PurchaseRate', label: 'Purchase Rate', icon: 'receipt' },
115
+ ];
116
+ }
117
+
118
+ // -------------------------------------------------------------------------
119
+ // Condition helpers
120
+ // -------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Returns true when the condition operator requires a value input.
124
+ * Operators "null" and "not null" are unary — no value field is needed.
125
+ * Used in the template to replace the {{#unless (or ...)}} pattern that
126
+ * violates the simple-unless lint rule.
127
+ */
128
+ showConditionValue(operator) {
129
+ return operator !== 'null' && operator !== 'not null';
130
+ }
131
+
132
+ // -------------------------------------------------------------------------
133
+ // Condition operators
134
+ // -------------------------------------------------------------------------
135
+
136
+ get conditionOperators() {
137
+ return [
138
+ { value: '=', label: '= equals' },
139
+ { value: '!=', label: '≠ not equals' },
140
+ { value: '>', label: '> greater than' },
141
+ { value: '>=', label: '≥ greater or equal' },
142
+ { value: '<', label: '< less than' },
143
+ { value: '<=', label: '≤ less or equal' },
144
+ { value: 'like', label: '~ contains' },
145
+ { value: 'not like', label: '!~ not contains' },
146
+ { value: 'in', label: 'in list' },
147
+ { value: 'not in', label: 'not in list' },
148
+ { value: 'null', label: 'is null' },
149
+ { value: 'not null', label: 'is not null' },
150
+ ];
151
+ }
152
+
153
+ // -------------------------------------------------------------------------
154
+ // Eager-load (with) — stored as array, edited as comma string
155
+ // -------------------------------------------------------------------------
156
+
157
+ get withString() {
158
+ return (this.withRelations ?? []).join(', ');
159
+ }
160
+
161
+ @action
162
+ updateWith(event) {
163
+ const raw = event.target.value;
164
+ this.withRelations = raw
165
+ .split(',')
166
+ .map((s) => s.trim())
167
+ .filter(Boolean);
168
+ }
169
+
170
+ // -------------------------------------------------------------------------
171
+ // Scalar field updates
172
+ // -------------------------------------------------------------------------
173
+
174
+ @action
175
+ updateLabel(event) {
176
+ const value = event.target.value;
177
+ this.label = value;
178
+ if (!this._variableNameManuallyEdited) {
179
+ this.variableName = this._deriveVariableName(value);
180
+ }
181
+ }
182
+
183
+ @action
184
+ updateVariableName(event) {
185
+ this._variableNameManuallyEdited = true;
186
+ this.variableName = event.target.value;
187
+ }
188
+
189
+ @action
190
+ updateDescription(event) {
191
+ this.description = event.target.value;
192
+ }
193
+
194
+ @action
195
+ updateLimit(event) {
196
+ const raw = event.target.value;
197
+ this.limit = raw === '' ? '' : parseInt(raw, 10);
198
+ }
199
+
200
+ @action
201
+ selectResourceType(value) {
202
+ this.modelType = value;
203
+ }
204
+
205
+ // -------------------------------------------------------------------------
206
+ // Conditions — mutate in-place then bump the array reference so Glimmer
207
+ // re-evaluates the {{#each}} without destroying existing DOM nodes.
208
+ // -------------------------------------------------------------------------
209
+
210
+ @action
211
+ addCondition() {
212
+ this.conditions = [...this.conditions, { field: '', operator: '=', value: '' }];
213
+ }
214
+
215
+ @action
216
+ updateConditionField(index, field, event) {
217
+ const value = event.target.value;
218
+ // Mutate the specific item in-place — the DOM node for this input stays
219
+ // alive and keeps focus. Then bump the array reference so Glimmer knows
220
+ // the list changed (needed for the operator select and value visibility).
221
+ const item = this.conditions[index];
222
+ if (item) {
223
+ Object.assign(item, { [field]: value });
224
+ this.conditions = [...this.conditions];
225
+ }
226
+ }
227
+
228
+ @action
229
+ removeCondition(index) {
230
+ this.conditions = this.conditions.filter((_, i) => i !== index);
231
+ }
232
+
233
+ // -------------------------------------------------------------------------
234
+ // Sort — same in-place mutation pattern as conditions
235
+ // -------------------------------------------------------------------------
236
+
237
+ @action
238
+ addSort() {
239
+ this.sort = [...this.sort, { field: '', direction: 'asc' }];
240
+ }
241
+
242
+ @action
243
+ updateSortField(index, field, event) {
244
+ const value = event.target.value;
245
+ const item = this.sort[index];
246
+ if (item) {
247
+ Object.assign(item, { [field]: value });
248
+ this.sort = [...this.sort];
249
+ }
250
+ }
251
+
252
+ @action
253
+ removeSort(index) {
254
+ this.sort = this.sort.filter((_, i) => i !== index);
255
+ }
256
+
257
+ // -------------------------------------------------------------------------
258
+ // Save / Cancel
259
+ // -------------------------------------------------------------------------
260
+
261
+ @action
262
+ save() {
263
+ this.errorMessage = null;
264
+
265
+ if (!this.label?.trim()) {
266
+ this.errorMessage = 'Label is required.';
267
+ return;
268
+ }
269
+ if (!this.modelType) {
270
+ this.errorMessage = 'Please select a resource type.';
271
+ return;
272
+ }
273
+
274
+ const data = {
275
+ uuid: this.args.query?.uuid ?? null,
276
+ label: this.label.trim(),
277
+ variable_name: this.variableName.trim() || this._deriveVariableName(this.label),
278
+ description: this.description?.trim() ?? '',
279
+ model_type: this.modelType,
280
+ conditions: this.conditions.filter((c) => c.field?.trim()),
281
+ sort: this.sort.filter((s) => s.field?.trim()),
282
+ limit: this.limit === '' ? null : this.limit,
283
+ with: this.withRelations,
284
+ };
285
+
286
+ if (this.args.onSave) {
287
+ this.args.onSave(data);
288
+ }
289
+ }
290
+
291
+ @action
292
+ cancel() {
293
+ this.errorMessage = null;
294
+ if (this.args.onClose) {
295
+ this.args.onClose();
296
+ }
297
+ }
298
+
299
+ // -------------------------------------------------------------------------
300
+ // Private
301
+ // -------------------------------------------------------------------------
302
+
303
+ _deriveVariableName(label) {
304
+ return (label ?? '')
305
+ .toLowerCase()
306
+ .replace(/[^a-z0-9]+/g, '_')
307
+ .replace(/^_+|_+$/g, '');
308
+ }
309
+ }