@5minds/node-red-dashboard-2-processcube-dynamic-table 2.0.3 → 2.0.4-develop-d30ad5-mfzfpl05

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.
@@ -1,31 +1,64 @@
1
1
  <template>
2
2
  <div class="ui-dynamic-table-container">
3
- <UIDynamicTableTitleText v-if="props.title_text && props.title_text.length > 0" :title="props.title_text"
4
- :style="props.title_style || 'default'" :customStyles="props.title_custom_text_styling || ''"
5
- :titleIcon="props.title_icon || ''" />
6
- <v-data-table :headers="headers" :items="tasks" :search="search" :sort-by="sortBy"
7
- :items-per-page="itemsPerPage" :items-per-page-options="itemsPerPageOptions" @update:sort-by="updateSortBy"
8
- @update:items-per-page="updateItemsPerPage" class="full-width-table">
9
- <template v-slot:top>
10
- <v-toolbar flat class="pb-4 pt-8">
11
- <v-text-field class="ml-3 search-input" style="margin-right: 50%;" v-model="search" label="Suchen"
12
- prepend-inner-icon="mdi-magnify" variant="outlined" hide-details single-line></v-text-field>
13
- </v-toolbar>
14
- </template>
15
- <template v-slot:header.actions>
16
- <v-btn class="reload-button" @click="reloadData" :disabled="isReloading" size="small"
17
- variant="outlined">
18
- <v-icon left :class="{ 'reload-spin': isReloading }">mdi-refresh</v-icon>
19
- </v-btn>
20
- </template>
3
+ <UIDynamicTableTitleText
4
+ v-if="props.title_text && props.title_text.length > 0"
5
+ :title="props.title_text"
6
+ :style="props.title_style || 'default'"
7
+ :customStyles="props.title_custom_text_styling || ''"
8
+ :titleIcon="props.title_icon || ''"
9
+ />
10
+
11
+ <!-- Search and Reload Controls -->
12
+ <v-toolbar flat class="pb-4 pt-8">
13
+ <v-text-field
14
+ class="search-input"
15
+ :class="{ 'mobile-search': isMobile }"
16
+ v-model="search"
17
+ label="Suchen"
18
+ prepend-inner-icon="mdi-magnify"
19
+ variant="outlined"
20
+ hide-details
21
+ single-line
22
+ >
23
+ </v-text-field>
24
+ <v-btn
25
+ class="reload-button ml-4"
26
+ @click="reloadData"
27
+ :disabled="isReloading"
28
+ size="small"
29
+ variant="outlined"
30
+ >
31
+ <v-icon left :class="{ 'reload-spin': isReloading }">mdi-refresh</v-icon>
32
+ </v-btn>
33
+ </v-toolbar>
34
+
35
+ <!-- Desktop Table View -->
36
+ <v-data-table
37
+ v-show="!isMobile"
38
+ :headers="headers"
39
+ :items="filteredTasks"
40
+ :sort-by="sortBy"
41
+ :items-per-page="itemsPerPage"
42
+ :items-per-page-options="itemsPerPageOptions"
43
+ @update:sort-by="updateSortBy"
44
+ @update:items-per-page="updateItemsPerPage"
45
+ class="full-width-table"
46
+ >
21
47
  <template v-slot:item.actions="{ item }">
22
48
  <v-container class="action-button-container">
23
- <v-container v-for="(action, index) in actions"
24
- style="padding: 0px; display: inline; width: fit-content; margin: 0px">
25
- <v-btn style="margin: 0px 2px" class="action-button"
26
- v-if="conditionCheck(action.condition, item)" :key="index" size="small"
27
- @click="actionFn(action, item)">
28
- {{ action.label }}
49
+ <v-container
50
+ v-for="(action, index) in actions"
51
+ style="padding: 0px; display: inline; width: fit-content; margin: 0px"
52
+ :key="index"
53
+ >
54
+ <v-btn
55
+ style="margin: 0px 2px"
56
+ class="action-button"
57
+ v-if="action && conditionCheck(action.condition, item)"
58
+ size="small"
59
+ @click="actionFn(action, item)"
60
+ >
61
+ {{ getActionLabel(action, item) }}
29
62
  </v-btn>
30
63
  </v-container>
31
64
  </v-container>
@@ -34,18 +67,63 @@
34
67
  <v-alert text="Keine Daten" style="margin: 12px"></v-alert>
35
68
  </template>
36
69
  </v-data-table>
70
+
71
+ <!-- Mobile Cards View -->
72
+ <div v-show="isMobile" class="mobile-cards-container">
73
+ <v-card
74
+ v-for="(item, index) in paginatedTasksForMobile"
75
+ :key="index"
76
+ class="mobile-card ma-2"
77
+ variant="outlined"
78
+ elevation="2"
79
+ >
80
+ <v-card-text class="pb-2">
81
+ <div v-for="header in dataHeaders" :key="header.key" class="mobile-card-row">
82
+ <span class="mobile-card-label">{{ header.title }}:</span>
83
+ <span class="mobile-card-value">{{ item[header.key] }}</span>
84
+ </div>
85
+ </v-card-text>
86
+ <v-card-actions v-if="actions && actions.length > 0" class="pt-0 px-2 pb-2 d-flex">
87
+ <v-btn
88
+ v-for="(action, actionIndex) in visibleActionsForMobile"
89
+ :key="actionIndex"
90
+ size="x-small"
91
+ color="primary"
92
+ variant="flat"
93
+ class="flex-grow-1 mx-1"
94
+ v-if="action && conditionCheck(action.condition, item)"
95
+ style="min-height: 28px; height: 28px; font-size: 0.75rem"
96
+ @click="actionFn(action, item)"
97
+ >
98
+ {{ getActionLabel(action, item) }}
99
+ </v-btn>
100
+ </v-card-actions>
101
+ </v-card>
102
+
103
+ <!-- No Data Alert for Mobile -->
104
+ <v-alert v-if="paginatedTasksForMobile.length === 0" text="Keine Daten" class="ma-2"></v-alert>
105
+ </div>
106
+
107
+ <!-- Pagination for Mobile -->
108
+ <v-pagination
109
+ v-if="isMobile && filteredTasks.length > itemsPerPage"
110
+ v-model="currentPage"
111
+ :length="Math.ceil(filteredTasks.length / itemsPerPage)"
112
+ class="mt-4"
113
+ ></v-pagination>
37
114
  </div>
38
115
  </template>
39
116
 
40
117
  <script>
41
118
  import { mapState } from 'vuex';
42
- import UIDynamicTableTitleText from './TitleText.vue'
119
+ import UIDynamicTableTitleText from './TitleText.vue';
43
120
  import { debounce } from 'lodash';
121
+ import jsonata from 'jsonata';
44
122
 
45
123
  export default {
46
124
  name: 'UIDynamicTable',
47
125
  components: {
48
- UIDynamicTableTitleText
126
+ UIDynamicTableTitleText,
49
127
  },
50
128
  inject: ['$socket'],
51
129
  props: {
@@ -67,6 +145,53 @@ export default {
67
145
  itemsPerPageStorageKey() {
68
146
  return `table_items_per_page_${this.id}`;
69
147
  },
148
+ isMobile() {
149
+ return this.$vuetify.display.mobile;
150
+ },
151
+ dataHeaders() {
152
+ return this.headers.filter((header) => header.key !== 'actions');
153
+ },
154
+ filteredTasks() {
155
+ let filtered = this.tasks;
156
+
157
+ // Apply search filter
158
+ if (this.search && this.search.trim() !== '') {
159
+ const searchTerm = this.search.toLowerCase();
160
+ filtered = filtered.filter((item) => {
161
+ return this.dataHeaders.some((header) => {
162
+ const value = item[header.key];
163
+ return value && value.toString().toLowerCase().includes(searchTerm);
164
+ });
165
+ });
166
+ }
167
+
168
+ // Don't apply pagination here - let v-data-table handle it for desktop
169
+ // For mobile, we'll handle pagination separately
170
+ return filtered;
171
+ },
172
+ paginatedTasksForMobile() {
173
+ if (!this.isMobile) return this.filteredTasks;
174
+
175
+ const startIndex = (this.currentPage - 1) * this.itemsPerPage;
176
+ const endIndex = startIndex + this.itemsPerPage;
177
+ return this.filteredTasks.slice(startIndex, endIndex);
178
+ },
179
+ visibleActionsForMobile() {
180
+ return this.actions.filter((action) => {
181
+ if (!action) return false;
182
+
183
+ // If no condition or empty condition, show the action
184
+ if (!action.condition || action.condition === '') return true;
185
+
186
+ // For conditions, we'll show all actions and let the click handler deal with it
187
+ // This avoids the Vue reactivity issue with calling conditionCheck in template
188
+ return true;
189
+ });
190
+ },
191
+ // Computed property to ensure reactivity to message changes
192
+ currentMessage() {
193
+ return this.messages[this.id] || {};
194
+ },
70
195
  },
71
196
  data() {
72
197
  return {
@@ -78,14 +203,17 @@ export default {
78
203
  isInitialized: false,
79
204
  sortBy: [],
80
205
  itemsPerPage: 10,
206
+ currentPage: 1,
81
207
  itemsPerPageOptions: [
82
208
  { value: 5, title: '5' },
83
209
  { value: 10, title: '10' },
84
210
  { value: 25, title: '25' },
85
211
  { value: 50, title: '50' },
86
212
  { value: 100, title: '100' },
87
- { value: -1, title: 'All' }
213
+ { value: -1, title: 'All' },
88
214
  ],
215
+ // Store JSONata evaluation results for each action-row combination
216
+ jsonataResults: new Map(),
89
217
  };
90
218
  },
91
219
  mounted() {
@@ -125,6 +253,14 @@ export default {
125
253
  this.$socket.emit('widget-action', this.id, msgArr);
126
254
  },
127
255
  actionFn(action, item) {
256
+ // Check condition at click time if it exists
257
+ if (action.condition && action.condition !== '') {
258
+ if (!this.conditionCheck(action.condition, item)) {
259
+ console.log('Action condition not met:', action.condition);
260
+ return; // Don't execute if condition fails
261
+ }
262
+ }
263
+
128
264
  const msg = this.messages[this.id] || {};
129
265
  msg.payload = item;
130
266
  this.send(
@@ -133,10 +269,31 @@ export default {
133
269
  );
134
270
  },
135
271
  initialize(msg) {
136
- this.tasks = msg.payload;
137
- this.actions = this.props.options;
272
+ // Handle null or undefined messages
273
+ if (!msg) {
274
+ this.tasks = [];
275
+ this.actions = (this.props.options || []).filter((action) => action != null);
276
+ this.headers = (this.props.columns || []).map((item) => ({
277
+ title: item.label,
278
+ key: item.value,
279
+ }));
280
+ this.headers.push({
281
+ title: '',
282
+ align: 'center',
283
+ key: 'actions',
284
+ sortable: false,
285
+ });
286
+ // Clear JSONata results cache
287
+ this.jsonataResults.clear();
288
+ return;
289
+ }
290
+
291
+ this.tasks = msg.payload || [];
138
292
 
139
- this.headers = this.props.columns.map((item) => ({
293
+ // Always use props.options for frontend-only evaluation
294
+ this.actions = (this.props.options || []).filter((action) => action != null);
295
+
296
+ this.headers = (this.props.columns || []).map((item) => ({
140
297
  title: item.label,
141
298
  key: item.value,
142
299
  }));
@@ -145,11 +302,156 @@ export default {
145
302
  title: '',
146
303
  align: 'center',
147
304
  key: 'actions',
148
- sortable: false
305
+ sortable: false,
149
306
  });
307
+
308
+ // Clear JSONata results cache when new data arrives
309
+ this.jsonataResults.clear();
310
+ },
311
+ getActionLabel(action, item) {
312
+ // Handle typed input system
313
+ if (action.label_type) {
314
+ switch (action.label_type) {
315
+ case 'str':
316
+ return action.label || '';
317
+
318
+ case 'msg':
319
+ if (action.label) {
320
+ const msgData = this.messages[this.id] || {};
321
+ return this.getNestedProperty(msgData, action.label) || '';
322
+ }
323
+ return '';
324
+
325
+ case 'row':
326
+ if (action.label) {
327
+ return this.getNestedProperty(item, action.label) || '';
328
+ }
329
+ return '';
330
+
331
+ case 'jsonata':
332
+ // For JSONata, check if we have the expression from backend
333
+ const msgData = this.messages[this.id] || {};
334
+ const actionIndex = this.actions.indexOf(action);
335
+
336
+ if (msgData._jsonataLabels && msgData._jsonataLabels[actionIndex]) {
337
+ const result = this.evaluateJsonata(msgData._jsonataLabels[actionIndex], msgData, item);
338
+ return result || '';
339
+ }
340
+ return action.label || '';
341
+
342
+ default:
343
+ return action.label || '';
344
+ }
345
+ }
346
+
347
+ // Fallback to legacy handling for backward compatibility
348
+ const legacy = this.evaluateLegacyLabel(action, item);
349
+ return legacy || action.label || '';
350
+ },
351
+
352
+ getNestedProperty(obj, path) {
353
+ if (!path) return '';
354
+
355
+ const keys = path.split('.');
356
+ let current = obj;
357
+
358
+ for (const key of keys) {
359
+ if (current && typeof current === 'object' && key in current) {
360
+ current = current[key];
361
+ } else {
362
+ return path; // Return original path as fallback
363
+ }
364
+ }
365
+
366
+ return current !== undefined && current !== null ? String(current) : path;
367
+ },
368
+
369
+ evaluateJsonata(expression, msgData, rowData) {
370
+ try {
371
+ // Create a unique key for this evaluation using expression and a hash of row data
372
+ const rowHash = JSON.stringify(rowData).slice(0, 50); // Truncate for performance
373
+ const key = `${expression}_${rowHash}`;
374
+
375
+ // Check if we already have a result for this combination
376
+ if (this.jsonataResults.has(key)) {
377
+ return this.jsonataResults.get(key);
378
+ }
379
+
380
+ // Create a combined context with both message and row data
381
+ const context = {
382
+ ...msgData,
383
+ $: rowData,
384
+ payload: msgData.payload,
385
+ msg: msgData,
386
+ };
387
+
388
+ const expr = jsonata(expression);
389
+ const result = expr.evaluate(context);
390
+
391
+ // Handle Promise return from JSONata
392
+ if (result && typeof result.then === 'function') {
393
+ // Store a pending indicator first
394
+ this.jsonataResults.set(key, expression);
395
+
396
+ // Handle the Promise
397
+ result
398
+ .then((resolvedValue) => {
399
+ const finalValue =
400
+ resolvedValue !== undefined && resolvedValue !== null ? String(resolvedValue) : '';
401
+ this.jsonataResults.set(key, finalValue);
402
+ this.$forceUpdate();
403
+ })
404
+ .catch((error) => {
405
+ console.warn('JSONata Promise evaluation error:', error.message);
406
+ this.jsonataResults.set(key, expression);
407
+ this.$forceUpdate();
408
+ });
409
+
410
+ return expression; // Return expression as placeholder
411
+ }
412
+
413
+ // Synchronous result
414
+ const finalValue = result !== undefined && result !== null ? String(result) : '';
415
+ this.jsonataResults.set(key, finalValue);
416
+ return finalValue;
417
+ } catch (error) {
418
+ console.warn('JSONata evaluation error:', error.message);
419
+ return expression; // fallback to original expression
420
+ }
421
+ },
422
+
423
+ evaluateLegacyLabel(action, item) {
424
+ // Legacy support for old "msg." and "row." prefixes
425
+ if (action.label && (action.label.startsWith('msg.') || action.label.startsWith('row.'))) {
426
+ let sourceObject;
427
+ let path;
428
+
429
+ if (action.label.startsWith('msg.')) {
430
+ sourceObject = this.messages[this.id] || {};
431
+ path = action.label.substring(4).split('.');
432
+ } else if (action.label.startsWith('row.')) {
433
+ sourceObject = item;
434
+ path = action.label.substring(4).split('.');
435
+ }
436
+
437
+ // Navigate through the object using the path
438
+ let value = sourceObject;
439
+ for (const key of path) {
440
+ if (value && typeof value === 'object' && key in value) {
441
+ value = value[key];
442
+ } else {
443
+ // Path not found, return original label as fallback
444
+ return action.label;
445
+ }
446
+ }
447
+
448
+ return value !== undefined && value !== null ? String(value) : action.label;
449
+ }
450
+
451
+ return action.label || '';
150
452
  },
151
453
  conditionCheck(condition, row) {
152
- if (condition == '') return true;
454
+ if (!condition || condition === '') return true;
153
455
  try {
154
456
  const func = new Function('row', `return ${condition}`);
155
457
  return func(row);
@@ -161,15 +463,18 @@ export default {
161
463
  reloadData() {
162
464
  this.isReloading = true;
163
465
 
164
- this.send({
165
- _client: {
166
- socketId: this.$socket.id,
167
- widgetId: this.id,
168
- },
169
- payload: {
170
- action: 'reload',
466
+ this.send(
467
+ {
468
+ _client: {
469
+ socketId: this.$socket.id,
470
+ widgetId: this.id,
471
+ },
472
+ payload: {
473
+ action: 'reload',
474
+ },
171
475
  },
172
- }, 0);
476
+ 0
477
+ );
173
478
 
174
479
  setTimeout(() => {
175
480
  this.isReloading = false;
@@ -184,14 +489,16 @@ export default {
184
489
  delete currentQuery[this.searchParamKey];
185
490
  }
186
491
 
187
- this.$router.replace({
188
- path: this.$route.path,
189
- query: currentQuery
190
- }).catch(err => {
191
- if (err.name !== 'NavigationDuplicated') {
192
- console.error('Router navigation error:', err);
193
- }
194
- });
492
+ this.$router
493
+ .replace({
494
+ path: this.$route.path,
495
+ query: currentQuery,
496
+ })
497
+ .catch((err) => {
498
+ if (err.name !== 'NavigationDuplicated') {
499
+ console.error('Router navigation error:', err);
500
+ }
501
+ });
195
502
  }, 500),
196
503
  loadSearchFromUrl() {
197
504
  const searchValue = this.$route.query[this.searchParamKey];
@@ -245,12 +552,84 @@ export default {
245
552
  this.loadSearchFromUrl();
246
553
  }
247
554
  },
248
- deep: true
249
- }
555
+ deep: true,
556
+ },
250
557
  },
251
558
  };
252
559
  </script>
253
560
 
254
561
  <style scoped>
255
562
  @import '../stylesheets/ui-dynamic-table.css';
563
+
564
+ /* Search bar responsive styles */
565
+ .search-input {
566
+ margin-left: 12px;
567
+ margin-right: 50%;
568
+ }
569
+
570
+ .mobile-search {
571
+ margin-right: 12px !important;
572
+ flex: 1;
573
+ }
574
+
575
+ /* Mobile Cards Styles */
576
+ .mobile-cards-container {
577
+ padding: 8px;
578
+ }
579
+
580
+ .mobile-card {
581
+ margin-bottom: 12px !important;
582
+ border-radius: 8px;
583
+ }
584
+
585
+ .mobile-card-row {
586
+ display: flex;
587
+ justify-content: space-between;
588
+ align-items: center;
589
+ padding: 4px 0;
590
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
591
+ }
592
+
593
+ .mobile-card-row:last-child {
594
+ border-bottom: none;
595
+ }
596
+
597
+ .mobile-card-label {
598
+ font-weight: 600;
599
+ color: rgba(0, 0, 0, 0.7);
600
+ flex: 0 0 40%;
601
+ font-size: 0.875rem;
602
+ }
603
+
604
+ .mobile-card-value {
605
+ flex: 1;
606
+ text-align: right;
607
+ word-break: break-word;
608
+ font-size: 0.875rem;
609
+ }
610
+
611
+ /* Responsive adjustments */
612
+ @media (max-width: 600px) {
613
+ .mobile-cards-container {
614
+ padding: 4px;
615
+ }
616
+
617
+ .mobile-card {
618
+ margin-bottom: 8px !important;
619
+ }
620
+
621
+ .mobile-card-label {
622
+ flex: 0 0 45%;
623
+ font-size: 0.8rem;
624
+ }
625
+
626
+ .mobile-card-value {
627
+ font-size: 0.8rem;
628
+ }
629
+
630
+ .search-input {
631
+ margin-left: 8px;
632
+ margin-right: 8px;
633
+ }
634
+ }
256
635
  </style>