@fleetbase/ember-ui 0.3.23 → 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 (43) hide show
  1. package/addon/components/template-builder/canvas.hbs +23 -0
  2. package/addon/components/template-builder/canvas.js +116 -0
  3. package/addon/components/template-builder/element-renderer.hbs +126 -0
  4. package/addon/components/template-builder/element-renderer.js +398 -0
  5. package/addon/components/template-builder/layers-panel.hbs +99 -0
  6. package/addon/components/template-builder/layers-panel.js +146 -0
  7. package/addon/components/template-builder/properties-panel/field.hbs +7 -0
  8. package/addon/components/template-builder/properties-panel/field.js +9 -0
  9. package/addon/components/template-builder/properties-panel/section.hbs +24 -0
  10. package/addon/components/template-builder/properties-panel/section.js +19 -0
  11. package/addon/components/template-builder/properties-panel.hbs +576 -0
  12. package/addon/components/template-builder/properties-panel.js +413 -0
  13. package/addon/components/template-builder/queries-panel.hbs +84 -0
  14. package/addon/components/template-builder/queries-panel.js +88 -0
  15. package/addon/components/template-builder/query-form.hbs +260 -0
  16. package/addon/components/template-builder/query-form.js +309 -0
  17. package/addon/components/template-builder/toolbar.hbs +134 -0
  18. package/addon/components/template-builder/toolbar.js +106 -0
  19. package/addon/components/template-builder/variable-picker.hbs +210 -0
  20. package/addon/components/template-builder/variable-picker.js +181 -0
  21. package/addon/components/template-builder.hbs +119 -0
  22. package/addon/components/template-builder.js +567 -0
  23. package/addon/helpers/string-starts-with.js +14 -0
  24. package/addon/services/template-builder.js +72 -0
  25. package/addon/styles/addon.css +1 -0
  26. package/addon/styles/components/badge.css +66 -12
  27. package/addon/styles/components/template-builder.css +297 -0
  28. package/addon/utils/get-currency.js +1 -1
  29. package/app/components/template-builder/canvas.js +1 -0
  30. package/app/components/template-builder/element-renderer.js +1 -0
  31. package/app/components/template-builder/layers-panel.js +1 -0
  32. package/app/components/template-builder/properties-panel/field.js +1 -0
  33. package/app/components/template-builder/properties-panel/section.js +1 -0
  34. package/app/components/template-builder/properties-panel.js +1 -0
  35. package/app/components/template-builder/queries-panel.js +1 -0
  36. package/app/components/template-builder/query-form.js +1 -0
  37. package/app/components/template-builder/toolbar.js +1 -0
  38. package/app/components/template-builder/variable-picker.js +1 -0
  39. package/app/components/template-builder.js +1 -0
  40. package/app/helpers/string-starts-with.js +1 -0
  41. package/app/services/template-builder.js +1 -0
  42. package/package.json +3 -2
  43. package/tsconfig.declarations.json +8 -8
@@ -0,0 +1,413 @@
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
+ * TemplateBuilderPropertiesPanelComponent
8
+ *
9
+ * Right-side panel that shows and edits all properties of the currently
10
+ * selected element. Sections are collapsed/expanded. When no element is
11
+ * selected, shows template-level canvas settings.
12
+ *
13
+ * @argument {Object} selectedElement - Currently selected element (or null)
14
+ * @argument {Object} template - The template object (for canvas settings)
15
+ * @argument {Array} contextSchemas - Variable schemas from the API
16
+ * @argument {Function} onUpdateElement - Called with (uuid, changes)
17
+ * @argument {Function} onUpdateTemplate - Called with changes to the template itself
18
+ * @argument {Function} onOpenVariablePicker - Called to open the variable picker modal
19
+ */
20
+ export default class TemplateBuilderPropertiesPanelComponent extends Component {
21
+ @service fetch;
22
+ @service notifications;
23
+
24
+ @tracked openSections = new Set(['position', 'size', 'style', 'text', 'content']);
25
+
26
+ /** @type {Boolean} Whether an image upload is in progress */
27
+ @tracked isUploadingImage = false;
28
+
29
+ /** @type {String|null} Filename of the most recently uploaded image */
30
+ @tracked uploadedImageFilename = null;
31
+
32
+ get hasSelection() {
33
+ return !!this.args.selectedElement;
34
+ }
35
+
36
+ get element() {
37
+ return this.args.selectedElement;
38
+ }
39
+
40
+ get elementType() {
41
+ return this.element?.type ?? null;
42
+ }
43
+
44
+ get isText() {
45
+ return this.elementType === 'text';
46
+ }
47
+ get isImage() {
48
+ return this.elementType === 'image';
49
+ }
50
+ get isTable() {
51
+ return this.elementType === 'table';
52
+ }
53
+ get isLine() {
54
+ return this.elementType === 'line';
55
+ }
56
+ get isShape() {
57
+ return this.elementType === 'shape';
58
+ }
59
+ get isQrCode() {
60
+ return this.elementType === 'qr_code';
61
+ }
62
+ get isBarcode() {
63
+ return this.elementType === 'barcode';
64
+ }
65
+ get hasTextContent() {
66
+ return this.isText;
67
+ }
68
+ get hasBorderOptions() {
69
+ return !this.isLine;
70
+ }
71
+
72
+ @action
73
+ isSectionOpen(section) {
74
+ return this.openSections.has(section);
75
+ }
76
+
77
+ @action
78
+ toggleSection(section) {
79
+ const next = new Set(this.openSections);
80
+ if (next.has(section)) {
81
+ next.delete(section);
82
+ } else {
83
+ next.add(section);
84
+ }
85
+ this.openSections = next;
86
+ }
87
+
88
+ @action
89
+ updateProp(prop, event) {
90
+ const value = event?.target ? event.target.value : event;
91
+ if (this.args.onUpdateElement && this.element) {
92
+ this.args.onUpdateElement(this.element.uuid, { [prop]: value });
93
+ }
94
+ }
95
+
96
+ @action
97
+ updateNumericProp(prop, event) {
98
+ const raw = event?.target ? event.target.value : event;
99
+ const value = raw === '' ? null : parseFloat(raw);
100
+ if (this.args.onUpdateElement && this.element) {
101
+ this.args.onUpdateElement(this.element.uuid, { [prop]: value });
102
+ }
103
+ }
104
+
105
+ @action
106
+ updateTemplateProp(prop, event) {
107
+ const value = event?.target ? event.target.value : event;
108
+ if (this.args.onUpdateTemplate) {
109
+ this.args.onUpdateTemplate({ [prop]: value });
110
+ }
111
+ }
112
+
113
+ @action
114
+ openVariablePicker(targetProp) {
115
+ if (this.args.onOpenVariablePicker) {
116
+ this.args.onOpenVariablePicker(targetProp, (variable) => {
117
+ if (this.args.onUpdateElement && this.element) {
118
+ const current = this.element[targetProp] ?? '';
119
+ this.args.onUpdateElement(this.element.uuid, { [targetProp]: current + variable });
120
+ }
121
+ });
122
+ }
123
+ }
124
+
125
+ get fontWeightOptions() {
126
+ return [
127
+ { value: '300', label: 'Light' },
128
+ { value: '400', label: 'Regular' },
129
+ { value: '500', label: 'Medium' },
130
+ { value: '600', label: 'Semi Bold' },
131
+ { value: '700', label: 'Bold' },
132
+ { value: '800', label: 'Extra Bold' },
133
+ { value: '900', label: 'Black' },
134
+ ];
135
+ }
136
+
137
+ get fontFamilyOptions() {
138
+ return [
139
+ { value: 'Inter, sans-serif', label: 'Inter' },
140
+ { value: 'Arial, sans-serif', label: 'Arial' },
141
+ { value: 'Helvetica, sans-serif', label: 'Helvetica' },
142
+ { value: 'Georgia, serif', label: 'Georgia' },
143
+ { value: 'Times New Roman, serif', label: 'Times New Roman' },
144
+ { value: 'Courier New, monospace', label: 'Courier New' },
145
+ { value: 'Roboto, sans-serif', label: 'Roboto' },
146
+ { value: 'Open Sans, sans-serif', label: 'Open Sans' },
147
+ ];
148
+ }
149
+
150
+ get textAlignOptions() {
151
+ return [
152
+ { value: 'left', icon: 'align-left' },
153
+ { value: 'center', icon: 'align-center' },
154
+ { value: 'right', icon: 'align-right' },
155
+ { value: 'justify', icon: 'align-justify' },
156
+ ];
157
+ }
158
+
159
+ get lineStyleOptions() {
160
+ return [
161
+ { value: 'solid', label: 'Solid' },
162
+ { value: 'dashed', label: 'Dashed' },
163
+ { value: 'dotted', label: 'Dotted' },
164
+ ];
165
+ }
166
+
167
+ get objectFitOptions() {
168
+ return [
169
+ { value: 'cover', label: 'Cover' },
170
+ { value: 'contain', label: 'Contain' },
171
+ { value: 'fill', label: 'Fill' },
172
+ { value: 'none', label: 'None' },
173
+ ];
174
+ }
175
+
176
+ // -------------------------------------------------------------------------
177
+ // Table helpers
178
+ // -------------------------------------------------------------------------
179
+
180
+ get tableColumns() {
181
+ return this.element?.columns ?? [];
182
+ }
183
+
184
+ get tableRows() {
185
+ return this.element?.rows ?? [];
186
+ }
187
+
188
+ /**
189
+ * The current data mode for the table: 'variable', 'query', or 'manual'.
190
+ * Stored explicitly as `data_source_mode` on the element so the mode is
191
+ * independent of whether the variable/query fields have been filled in yet.
192
+ * Defaults to 'manual' for new elements.
193
+ */
194
+ get tableDataMode() {
195
+ return this.element?.data_source_mode ?? 'manual';
196
+ }
197
+
198
+ @action
199
+ setTableDataMode(mode) {
200
+ if (!this.args.onUpdateElement || !this.element) return;
201
+ const changes = { data_source_mode: mode };
202
+ if (mode === 'manual') {
203
+ // Clear variable/query fields when switching to manual
204
+ changes.data_source = null;
205
+ changes.query_endpoint = null;
206
+ changes.query_params = [];
207
+ changes.query_response_path = null;
208
+ } else if (mode === 'variable') {
209
+ // Clear query fields when switching to variable
210
+ changes.query_endpoint = null;
211
+ changes.query_params = [];
212
+ changes.query_response_path = null;
213
+ } else if (mode === 'query') {
214
+ // Clear variable field when switching to query
215
+ changes.data_source = null;
216
+ // Seed empty query_params array if not already present
217
+ if (!this.element.query_params) {
218
+ changes.query_params = [];
219
+ }
220
+ }
221
+ this.args.onUpdateElement(this.element.uuid, changes);
222
+ }
223
+
224
+ // ── Query data source helpers ────────────────────────────────────────────
225
+
226
+ get queryParams() {
227
+ return this.element?.query_params ?? [];
228
+ }
229
+
230
+ @action
231
+ addQueryParam() {
232
+ if (!this.args.onUpdateElement || !this.element) return;
233
+ const params = [...this.queryParams, { key: '', value: '' }];
234
+ this.args.onUpdateElement(this.element.uuid, { query_params: params });
235
+ }
236
+
237
+ @action
238
+ removeQueryParam(index) {
239
+ if (!this.args.onUpdateElement || !this.element) return;
240
+ const params = this.queryParams.filter((_, i) => i !== index);
241
+ this.args.onUpdateElement(this.element.uuid, { query_params: params });
242
+ }
243
+
244
+ @action
245
+ updateQueryParamKey(index, event) {
246
+ if (!this.args.onUpdateElement || !this.element) return;
247
+ const params = this.queryParams.map((p, i) => (i === index ? { ...p, key: event.target.value } : p));
248
+ this.args.onUpdateElement(this.element.uuid, { query_params: params });
249
+ }
250
+
251
+ @action
252
+ updateQueryParamValue(index, event) {
253
+ if (!this.args.onUpdateElement || !this.element) return;
254
+ const params = this.queryParams.map((p, i) => (i === index ? { ...p, value: event.target.value } : p));
255
+ this.args.onUpdateElement(this.element.uuid, { query_params: params });
256
+ }
257
+
258
+ @action
259
+ addColumn() {
260
+ if (!this.args.onUpdateElement || !this.element) return;
261
+ const columns = [...this.tableColumns, { label: '', key: '' }];
262
+ this.args.onUpdateElement(this.element.uuid, { columns });
263
+ }
264
+
265
+ @action
266
+ removeColumn(index) {
267
+ if (!this.args.onUpdateElement || !this.element) return;
268
+ const columns = this.tableColumns.filter((_, i) => i !== index);
269
+ // Also remove the corresponding key from all rows
270
+ const removedKey = this.tableColumns[index]?.key;
271
+ const rows = removedKey
272
+ ? this.tableRows.map((row) => {
273
+ const next = Object.assign({}, row);
274
+ delete next[removedKey];
275
+ return next;
276
+ })
277
+ : this.tableRows;
278
+ this.args.onUpdateElement(this.element.uuid, { columns, rows });
279
+ }
280
+
281
+ @action
282
+ updateColumnLabel(index, event) {
283
+ if (!this.args.onUpdateElement || !this.element) return;
284
+ const columns = this.tableColumns.map((col, i) => (i === index ? { ...col, label: event.target.value } : col));
285
+ this.args.onUpdateElement(this.element.uuid, { columns });
286
+ }
287
+
288
+ @action
289
+ updateColumnKey(index, event) {
290
+ if (!this.args.onUpdateElement || !this.element) return;
291
+ const oldKey = this.tableColumns[index]?.key;
292
+ const newKey = event.target.value;
293
+ const columns = this.tableColumns.map((col, i) => (i === index ? { ...col, key: newKey } : col));
294
+ // Rename the key in all existing rows
295
+ const rows = this.tableRows.map((row) => {
296
+ const next = Object.assign({}, row);
297
+ if (oldKey && oldKey !== newKey) {
298
+ next[newKey] = next[oldKey] ?? '';
299
+ delete next[oldKey];
300
+ }
301
+ return next;
302
+ });
303
+ this.args.onUpdateElement(this.element.uuid, { columns, rows });
304
+ }
305
+
306
+ @action
307
+ addRow() {
308
+ if (!this.args.onUpdateElement || !this.element) return;
309
+ // Build an empty row with a key for each defined column
310
+ const emptyRow = {};
311
+ this.tableColumns.forEach((col) => {
312
+ if (col.key) emptyRow[col.key] = '';
313
+ });
314
+ const rows = [...this.tableRows, emptyRow];
315
+ this.args.onUpdateElement(this.element.uuid, { rows });
316
+ }
317
+
318
+ @action
319
+ removeRow(index) {
320
+ if (!this.args.onUpdateElement || !this.element) return;
321
+ const rows = this.tableRows.filter((_, i) => i !== index);
322
+ this.args.onUpdateElement(this.element.uuid, { rows });
323
+ }
324
+
325
+ @action
326
+ updateRowCell(rowIndex, key, event) {
327
+ if (!this.args.onUpdateElement || !this.element) return;
328
+ const rows = this.tableRows.map((row, i) => (i === rowIndex ? { ...row, [key]: event.target.value } : row));
329
+ this.args.onUpdateElement(this.element.uuid, { rows });
330
+ }
331
+
332
+ // -------------------------------------------------------------------------
333
+ // Image helpers
334
+ // -------------------------------------------------------------------------
335
+
336
+ get imageIsVariable() {
337
+ const src = this.element?.src ?? '';
338
+ return src.length > 0 && src.includes('{');
339
+ }
340
+
341
+ /**
342
+ * True when the image src is a URL (uploaded file) rather than a variable token.
343
+ */
344
+ get imageIsUploaded() {
345
+ const src = this.element?.src ?? '';
346
+ return src.length > 0 && !src.includes('{');
347
+ }
348
+
349
+ @action
350
+ async onImageFileAdded(file) {
351
+ // Guard against duplicate calls (ember-file-upload can fire twice)
352
+ if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) return;
353
+
354
+ this.isUploadingImage = true;
355
+ this.uploadedImageFilename = file.name;
356
+
357
+ try {
358
+ await this.fetch.uploadFile.perform(
359
+ file,
360
+ {
361
+ path: 'uploads/template-builder/images',
362
+ type: 'template_image',
363
+ },
364
+ (uploadedFile) => {
365
+ this.isUploadingImage = false;
366
+ // Store the URL on the element but keep the filename visible in the UI
367
+ if (this.args.onUpdateElement && this.element) {
368
+ this.args.onUpdateElement(this.element.uuid, { src: uploadedFile.url });
369
+ }
370
+ }
371
+ );
372
+ } catch (err) {
373
+ this.isUploadingImage = false;
374
+ this.uploadedImageFilename = null;
375
+ if (this.notifications) {
376
+ this.notifications.error(`Image upload failed: ${err.message}`);
377
+ }
378
+ }
379
+ }
380
+
381
+ @action
382
+ clearImageSrc() {
383
+ this.uploadedImageFilename = null;
384
+ if (this.args.onUpdateElement && this.element) {
385
+ this.args.onUpdateElement(this.element.uuid, { src: '' });
386
+ }
387
+ }
388
+
389
+ get shapeOptions() {
390
+ return [
391
+ { value: 'rectangle', label: 'Rectangle' },
392
+ { value: 'circle', label: 'Circle' },
393
+ ];
394
+ }
395
+
396
+ get paperSizeOptions() {
397
+ return [
398
+ { value: 'A4', label: 'A4 (210 × 297 mm)' },
399
+ { value: 'A3', label: 'A3 (297 × 420 mm)' },
400
+ { value: 'A5', label: 'A5 (148 × 210 mm)' },
401
+ { value: 'Letter', label: 'Letter (216 × 279 mm)' },
402
+ { value: 'Legal', label: 'Legal (216 × 356 mm)' },
403
+ { value: 'custom', label: 'Custom' },
404
+ ];
405
+ }
406
+
407
+ get orientationOptions() {
408
+ return [
409
+ { value: 'portrait', label: 'Portrait' },
410
+ { value: 'landscape', label: 'Landscape' },
411
+ ];
412
+ }
413
+ }
@@ -0,0 +1,84 @@
1
+ {{! Template Builder Queries Panel }}
2
+ <div class="tb-queries-panel flex flex-col h-full" ...attributes>
3
+
4
+ {{! Header }}
5
+ <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
6
+ <span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Queries</span>
7
+ <button
8
+ type="button"
9
+ class="flex items-center space-x-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
10
+ title="Add query"
11
+ {{on "click" this.openAddForm}}
12
+ >
13
+ <FaIcon @icon="plus" class="w-3 h-3" />
14
+ <span>Add</span>
15
+ </button>
16
+ </div>
17
+
18
+ {{! Query list }}
19
+ <div class="flex-1 overflow-y-auto">
20
+
21
+ {{#if this.queries.length}}
22
+ {{#each this.queries as |query|}}
23
+ <div
24
+ class="tb-query-row group flex items-start px-3 py-2 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
25
+ >
26
+ {{! Icon + text }}
27
+ <div class="flex-1 min-w-0 mr-2">
28
+ <div class="flex items-center space-x-1.5">
29
+ <FaIcon @icon="database" class="w-3 h-3 text-indigo-500 flex-shrink-0" />
30
+ <span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{{query.label}}</span>
31
+ </div>
32
+ <div class="flex items-center space-x-1.5 mt-0.5">
33
+ <span class="font-mono text-xs text-indigo-600 dark:text-indigo-400 truncate">&#123;{{query.variable_name}}&#125;</span>
34
+ {{#if query.model_type}}
35
+ <span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{query.model_type}}</span>
36
+ {{/if}}
37
+ {{! Show "unsaved" badge for queries created before the template was saved }}
38
+ {{#if (string-starts-with query.uuid "_new_")}}
39
+ <span class="flex-shrink-0 px-1 py-0 rounded text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">unsaved</span>
40
+ {{/if}}
41
+ </div>
42
+ </div>
43
+
44
+ {{! Action buttons (shown on hover) }}
45
+ <div class="flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
46
+ <button
47
+ type="button"
48
+ class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
49
+ title="Edit query"
50
+ {{on "click" (fn this.openEditForm query)}}
51
+ >
52
+ <FaIcon @icon="pen" class="w-3 h-3" />
53
+ </button>
54
+ <button
55
+ type="button"
56
+ class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 dark:hover:text-red-400"
57
+ title="Delete query"
58
+ {{on "click" (fn this.deleteQuery query)}}
59
+ >
60
+ <FaIcon @icon="trash" class="w-3 h-3" />
61
+ </button>
62
+ </div>
63
+ </div>
64
+ {{/each}}
65
+
66
+ {{else}}
67
+ <div class="flex flex-col items-center justify-center py-8 px-4 text-center">
68
+ <FaIcon @icon="database" class="w-6 h-6 text-gray-300 dark:text-gray-600 mb-2" />
69
+ <p class="text-xs text-gray-400 dark:text-gray-500">No queries yet.</p>
70
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Queries load data from Fleetbase models at render time and are saved with the template.</p>
71
+ </div>
72
+ {{/if}}
73
+
74
+ </div>
75
+
76
+ {{! Query Form Modal }}
77
+ <TemplateBuilder::QueryForm
78
+ @isOpen={{this.isFormOpen}}
79
+ @query={{this.editingQuery}}
80
+ @onSave={{this.handleQuerySave}}
81
+ @onClose={{this.closeForm}}
82
+ />
83
+
84
+ </div>
@@ -0,0 +1,88 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+
5
+ /**
6
+ * TemplateBuilderQueriesPanelComponent
7
+ *
8
+ * Lists the TemplateQuery records for the current template and provides
9
+ * add / edit / delete operations. All mutations are propagated upward via
10
+ * @onQueriesChange — no API calls are made here. The parent (template-builder)
11
+ * includes the full queries array in the template save payload, so everything
12
+ * persists in one request when the user clicks Save.
13
+ *
14
+ * @argument {Array} queries - Current list of query objects (from parent state)
15
+ * @argument {Function} onQueriesChange - Called with the updated queries array after any mutation
16
+ */
17
+ export default class TemplateBuilderQueriesPanelComponent extends Component {
18
+ @tracked isFormOpen = false;
19
+ @tracked editingQuery = null;
20
+
21
+ // -------------------------------------------------------------------------
22
+ // Computed
23
+ // -------------------------------------------------------------------------
24
+
25
+ get queries() {
26
+ return this.args.queries ?? [];
27
+ }
28
+
29
+ // -------------------------------------------------------------------------
30
+ // Form open/close
31
+ // -------------------------------------------------------------------------
32
+
33
+ @action
34
+ openAddForm() {
35
+ this.editingQuery = null;
36
+ this.isFormOpen = true;
37
+ }
38
+
39
+ @action
40
+ openEditForm(query) {
41
+ this.editingQuery = query;
42
+ this.isFormOpen = true;
43
+ }
44
+
45
+ @action
46
+ closeForm() {
47
+ this.isFormOpen = false;
48
+ this.editingQuery = null;
49
+ }
50
+
51
+ // -------------------------------------------------------------------------
52
+ // CRUD — all mutations notify the parent via @onQueriesChange
53
+ // -------------------------------------------------------------------------
54
+
55
+ @action
56
+ handleQuerySave(queryData) {
57
+ let updated;
58
+
59
+ if (queryData.uuid) {
60
+ // Update existing query in the list
61
+ updated = this.queries.map((q) => (q.uuid === queryData.uuid ? { ...q, ...queryData } : q));
62
+ } else {
63
+ // New query — assign a temporary client-side UUID so it can be
64
+ // identified for edits/deletes before the template is saved.
65
+ const tempUuid = `_new_${Date.now()}`;
66
+ updated = [...this.queries, { ...queryData, uuid: tempUuid }];
67
+ }
68
+
69
+ this._notify(updated);
70
+ this.closeForm();
71
+ }
72
+
73
+ @action
74
+ deleteQuery(query) {
75
+ const updated = this.queries.filter((q) => q.uuid !== query.uuid);
76
+ this._notify(updated);
77
+ }
78
+
79
+ // -------------------------------------------------------------------------
80
+ // Private
81
+ // -------------------------------------------------------------------------
82
+
83
+ _notify(queries) {
84
+ if (this.args.onQueriesChange) {
85
+ this.args.onQueriesChange(queries);
86
+ }
87
+ }
88
+ }