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

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