@5minds/node-red-dashboard-2-processcube-dynamic-table 2.0.3-develop-8a7c9e-mets8ufl → 2.0.3-develop-e1e056-mewuxjwr
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/nodes/dynamic-table.html +392 -369
- package/nodes/dynamic-table.js +11 -1
- package/package.json +89 -88
- package/resources/ui-dynamic-table.umd.js +22 -19
- package/ui/components/UIDynamicTable.vue +251 -82
|
@@ -1,46 +1,63 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="ui-dynamic-table-container">
|
|
3
|
-
<UIDynamicTableTitleText
|
|
4
|
-
|
|
5
|
-
:
|
|
6
|
-
|
|
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
|
+
|
|
7
11
|
<!-- Search and Reload Controls -->
|
|
8
12
|
<v-toolbar flat class="pb-4 pt-8">
|
|
9
|
-
<v-text-field
|
|
10
|
-
class="search-input"
|
|
13
|
+
<v-text-field
|
|
14
|
+
class="search-input"
|
|
11
15
|
:class="{ 'mobile-search': isMobile }"
|
|
12
|
-
v-model="search"
|
|
16
|
+
v-model="search"
|
|
13
17
|
label="Suchen"
|
|
14
|
-
prepend-inner-icon="mdi-magnify"
|
|
15
|
-
variant="outlined"
|
|
16
|
-
hide-details
|
|
17
|
-
single-line
|
|
18
|
+
prepend-inner-icon="mdi-magnify"
|
|
19
|
+
variant="outlined"
|
|
20
|
+
hide-details
|
|
21
|
+
single-line
|
|
22
|
+
>
|
|
18
23
|
</v-text-field>
|
|
19
|
-
<v-btn
|
|
20
|
-
|
|
24
|
+
<v-btn
|
|
25
|
+
class="reload-button ml-4"
|
|
26
|
+
@click="reloadData"
|
|
27
|
+
:disabled="isReloading"
|
|
28
|
+
size="small"
|
|
29
|
+
variant="outlined"
|
|
30
|
+
>
|
|
21
31
|
<v-icon left :class="{ 'reload-spin': isReloading }">mdi-refresh</v-icon>
|
|
22
32
|
</v-btn>
|
|
23
33
|
</v-toolbar>
|
|
24
34
|
|
|
25
35
|
<!-- Desktop Table View -->
|
|
26
|
-
<v-data-table
|
|
36
|
+
<v-data-table
|
|
27
37
|
v-show="!isMobile"
|
|
28
|
-
:headers="headers"
|
|
29
|
-
:items="filteredTasks"
|
|
38
|
+
:headers="headers"
|
|
39
|
+
:items="filteredTasks"
|
|
30
40
|
:sort-by="sortBy"
|
|
31
|
-
:items-per-page="itemsPerPage"
|
|
32
|
-
:items-per-page-options="itemsPerPageOptions"
|
|
41
|
+
:items-per-page="itemsPerPage"
|
|
42
|
+
:items-per-page-options="itemsPerPageOptions"
|
|
33
43
|
@update:sort-by="updateSortBy"
|
|
34
|
-
@update:items-per-page="updateItemsPerPage"
|
|
35
|
-
class="full-width-table"
|
|
36
|
-
|
|
44
|
+
@update:items-per-page="updateItemsPerPage"
|
|
45
|
+
class="full-width-table"
|
|
46
|
+
>
|
|
37
47
|
<template v-slot:item.actions="{ item }">
|
|
38
48
|
<v-container class="action-button-container">
|
|
39
|
-
<v-container
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
>
|
|
44
61
|
{{ getActionLabel(action, item) }}
|
|
45
62
|
</v-btn>
|
|
46
63
|
</v-container>
|
|
@@ -53,12 +70,13 @@
|
|
|
53
70
|
|
|
54
71
|
<!-- Mobile Cards View -->
|
|
55
72
|
<div v-show="isMobile" class="mobile-cards-container">
|
|
56
|
-
<v-card
|
|
57
|
-
v-for="(item, index) in paginatedTasksForMobile"
|
|
58
|
-
:key="index"
|
|
59
|
-
class="mobile-card ma-2"
|
|
73
|
+
<v-card
|
|
74
|
+
v-for="(item, index) in paginatedTasksForMobile"
|
|
75
|
+
:key="index"
|
|
76
|
+
class="mobile-card ma-2"
|
|
60
77
|
variant="outlined"
|
|
61
|
-
elevation="2"
|
|
78
|
+
elevation="2"
|
|
79
|
+
>
|
|
62
80
|
<v-card-text class="pb-2">
|
|
63
81
|
<div v-for="header in dataHeaders" :key="header.key" class="mobile-card-row">
|
|
64
82
|
<span class="mobile-card-label">{{ header.title }}:</span>
|
|
@@ -66,26 +84,27 @@
|
|
|
66
84
|
</div>
|
|
67
85
|
</v-card-text>
|
|
68
86
|
<v-card-actions v-if="actions && actions.length > 0" class="pt-0 px-2 pb-2 d-flex">
|
|
69
|
-
<v-btn
|
|
87
|
+
<v-btn
|
|
70
88
|
v-for="(action, actionIndex) in visibleActionsForMobile"
|
|
71
|
-
:key="actionIndex"
|
|
89
|
+
:key="actionIndex"
|
|
72
90
|
size="x-small"
|
|
73
91
|
color="primary"
|
|
74
92
|
variant="flat"
|
|
75
93
|
class="flex-grow-1 mx-1"
|
|
76
|
-
style="min-height: 28px; height: 28px; font-size: 0.75rem
|
|
77
|
-
@click="actionFn(action, item)"
|
|
94
|
+
style="min-height: 28px; height: 28px; font-size: 0.75rem"
|
|
95
|
+
@click="actionFn(action, item)"
|
|
96
|
+
>
|
|
78
97
|
{{ getActionLabel(action, item) }}
|
|
79
98
|
</v-btn>
|
|
80
99
|
</v-card-actions>
|
|
81
100
|
</v-card>
|
|
82
|
-
|
|
101
|
+
|
|
83
102
|
<!-- No Data Alert for Mobile -->
|
|
84
103
|
<v-alert v-if="paginatedTasksForMobile.length === 0" text="Keine Daten" class="ma-2"></v-alert>
|
|
85
104
|
</div>
|
|
86
105
|
|
|
87
106
|
<!-- Pagination for Mobile -->
|
|
88
|
-
<v-pagination
|
|
107
|
+
<v-pagination
|
|
89
108
|
v-if="isMobile && filteredTasks.length > itemsPerPage"
|
|
90
109
|
v-model="currentPage"
|
|
91
110
|
:length="Math.ceil(filteredTasks.length / itemsPerPage)"
|
|
@@ -96,13 +115,14 @@
|
|
|
96
115
|
|
|
97
116
|
<script>
|
|
98
117
|
import { mapState } from 'vuex';
|
|
99
|
-
import UIDynamicTableTitleText from './TitleText.vue'
|
|
118
|
+
import UIDynamicTableTitleText from './TitleText.vue';
|
|
100
119
|
import { debounce } from 'lodash';
|
|
120
|
+
import jsonata from 'jsonata';
|
|
101
121
|
|
|
102
122
|
export default {
|
|
103
123
|
name: 'UIDynamicTable',
|
|
104
124
|
components: {
|
|
105
|
-
UIDynamicTableTitleText
|
|
125
|
+
UIDynamicTableTitleText,
|
|
106
126
|
},
|
|
107
127
|
inject: ['$socket'],
|
|
108
128
|
props: {
|
|
@@ -128,45 +148,49 @@ export default {
|
|
|
128
148
|
return this.$vuetify.display.mobile;
|
|
129
149
|
},
|
|
130
150
|
dataHeaders() {
|
|
131
|
-
return this.headers.filter(header => header.key !== 'actions');
|
|
151
|
+
return this.headers.filter((header) => header.key !== 'actions');
|
|
132
152
|
},
|
|
133
153
|
filteredTasks() {
|
|
134
154
|
let filtered = this.tasks;
|
|
135
|
-
|
|
155
|
+
|
|
136
156
|
// Apply search filter
|
|
137
157
|
if (this.search && this.search.trim() !== '') {
|
|
138
158
|
const searchTerm = this.search.toLowerCase();
|
|
139
|
-
filtered = filtered.filter(item => {
|
|
140
|
-
return this.dataHeaders.some(header => {
|
|
159
|
+
filtered = filtered.filter((item) => {
|
|
160
|
+
return this.dataHeaders.some((header) => {
|
|
141
161
|
const value = item[header.key];
|
|
142
162
|
return value && value.toString().toLowerCase().includes(searchTerm);
|
|
143
163
|
});
|
|
144
164
|
});
|
|
145
165
|
}
|
|
146
|
-
|
|
166
|
+
|
|
147
167
|
// Don't apply pagination here - let v-data-table handle it for desktop
|
|
148
168
|
// For mobile, we'll handle pagination separately
|
|
149
169
|
return filtered;
|
|
150
170
|
},
|
|
151
171
|
paginatedTasksForMobile() {
|
|
152
172
|
if (!this.isMobile) return this.filteredTasks;
|
|
153
|
-
|
|
173
|
+
|
|
154
174
|
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
|
|
155
175
|
const endIndex = startIndex + this.itemsPerPage;
|
|
156
176
|
return this.filteredTasks.slice(startIndex, endIndex);
|
|
157
177
|
},
|
|
158
178
|
visibleActionsForMobile() {
|
|
159
|
-
return this.actions.filter(action => {
|
|
179
|
+
return this.actions.filter((action) => {
|
|
160
180
|
if (!action) return false;
|
|
161
|
-
|
|
181
|
+
|
|
162
182
|
// If no condition or empty condition, show the action
|
|
163
183
|
if (!action.condition || action.condition === '') return true;
|
|
164
|
-
|
|
184
|
+
|
|
165
185
|
// For conditions, we'll show all actions and let the click handler deal with it
|
|
166
186
|
// This avoids the Vue reactivity issue with calling conditionCheck in template
|
|
167
187
|
return true;
|
|
168
188
|
});
|
|
169
|
-
}
|
|
189
|
+
},
|
|
190
|
+
// Computed property to ensure reactivity to message changes
|
|
191
|
+
currentMessage() {
|
|
192
|
+
return this.messages[this.id] || {};
|
|
193
|
+
},
|
|
170
194
|
},
|
|
171
195
|
data() {
|
|
172
196
|
return {
|
|
@@ -185,8 +209,10 @@ export default {
|
|
|
185
209
|
{ value: 25, title: '25' },
|
|
186
210
|
{ value: 50, title: '50' },
|
|
187
211
|
{ value: 100, title: '100' },
|
|
188
|
-
{ value: -1, title: 'All' }
|
|
212
|
+
{ value: -1, title: 'All' },
|
|
189
213
|
],
|
|
214
|
+
// Store JSONata evaluation results for each action-row combination
|
|
215
|
+
jsonataResults: new Map(),
|
|
190
216
|
};
|
|
191
217
|
},
|
|
192
218
|
mounted() {
|
|
@@ -233,7 +259,7 @@ export default {
|
|
|
233
259
|
return; // Don't execute if condition fails
|
|
234
260
|
}
|
|
235
261
|
}
|
|
236
|
-
|
|
262
|
+
|
|
237
263
|
const msg = this.messages[this.id] || {};
|
|
238
264
|
msg.payload = item;
|
|
239
265
|
this.send(
|
|
@@ -242,8 +268,29 @@ export default {
|
|
|
242
268
|
);
|
|
243
269
|
},
|
|
244
270
|
initialize(msg) {
|
|
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
|
+
|
|
245
290
|
this.tasks = msg.payload || [];
|
|
246
|
-
|
|
291
|
+
|
|
292
|
+
// Always use props.options for frontend-only evaluation
|
|
293
|
+
this.actions = (this.props.options || []).filter((action) => action != null);
|
|
247
294
|
|
|
248
295
|
this.headers = (this.props.columns || []).map((item) => ({
|
|
249
296
|
title: item.label,
|
|
@@ -254,22 +301,138 @@ export default {
|
|
|
254
301
|
title: '',
|
|
255
302
|
align: 'center',
|
|
256
303
|
key: 'actions',
|
|
257
|
-
sortable: false
|
|
304
|
+
sortable: false,
|
|
258
305
|
});
|
|
306
|
+
|
|
307
|
+
// Clear JSONata results cache when new data arrives
|
|
308
|
+
this.jsonataResults.clear();
|
|
259
309
|
},
|
|
260
310
|
getActionLabel(action, item) {
|
|
261
|
-
|
|
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.'))) {
|
|
262
425
|
let sourceObject;
|
|
263
426
|
let path;
|
|
264
|
-
|
|
427
|
+
|
|
265
428
|
if (action.label.startsWith('msg.')) {
|
|
266
429
|
sourceObject = this.messages[this.id] || {};
|
|
267
430
|
path = action.label.substring(4).split('.');
|
|
268
|
-
} else if (action.label.startsWith('
|
|
431
|
+
} else if (action.label.startsWith('row.')) {
|
|
269
432
|
sourceObject = item;
|
|
270
|
-
path = action.label.substring(
|
|
433
|
+
path = action.label.substring(4).split('.');
|
|
271
434
|
}
|
|
272
|
-
|
|
435
|
+
|
|
273
436
|
// Navigate through the object using the path
|
|
274
437
|
let value = sourceObject;
|
|
275
438
|
for (const key of path) {
|
|
@@ -280,10 +443,11 @@ export default {
|
|
|
280
443
|
return action.label;
|
|
281
444
|
}
|
|
282
445
|
}
|
|
283
|
-
|
|
446
|
+
|
|
284
447
|
return value !== undefined && value !== null ? String(value) : action.label;
|
|
285
448
|
}
|
|
286
|
-
|
|
449
|
+
|
|
450
|
+
return action.label || '';
|
|
287
451
|
},
|
|
288
452
|
conditionCheck(condition, row) {
|
|
289
453
|
if (!condition || condition === '') return true;
|
|
@@ -298,15 +462,18 @@ export default {
|
|
|
298
462
|
reloadData() {
|
|
299
463
|
this.isReloading = true;
|
|
300
464
|
|
|
301
|
-
this.send(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
465
|
+
this.send(
|
|
466
|
+
{
|
|
467
|
+
_client: {
|
|
468
|
+
socketId: this.$socket.id,
|
|
469
|
+
widgetId: this.id,
|
|
470
|
+
},
|
|
471
|
+
payload: {
|
|
472
|
+
action: 'reload',
|
|
473
|
+
},
|
|
308
474
|
},
|
|
309
|
-
|
|
475
|
+
0
|
|
476
|
+
);
|
|
310
477
|
|
|
311
478
|
setTimeout(() => {
|
|
312
479
|
this.isReloading = false;
|
|
@@ -321,14 +488,16 @@ export default {
|
|
|
321
488
|
delete currentQuery[this.searchParamKey];
|
|
322
489
|
}
|
|
323
490
|
|
|
324
|
-
this.$router
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
+
});
|
|
332
501
|
}, 500),
|
|
333
502
|
loadSearchFromUrl() {
|
|
334
503
|
const searchValue = this.$route.query[this.searchParamKey];
|
|
@@ -382,8 +551,8 @@ export default {
|
|
|
382
551
|
this.loadSearchFromUrl();
|
|
383
552
|
}
|
|
384
553
|
},
|
|
385
|
-
deep: true
|
|
386
|
-
}
|
|
554
|
+
deep: true,
|
|
555
|
+
},
|
|
387
556
|
},
|
|
388
557
|
};
|
|
389
558
|
</script>
|
|
@@ -443,20 +612,20 @@ export default {
|
|
|
443
612
|
.mobile-cards-container {
|
|
444
613
|
padding: 4px;
|
|
445
614
|
}
|
|
446
|
-
|
|
615
|
+
|
|
447
616
|
.mobile-card {
|
|
448
617
|
margin-bottom: 8px !important;
|
|
449
618
|
}
|
|
450
|
-
|
|
619
|
+
|
|
451
620
|
.mobile-card-label {
|
|
452
621
|
flex: 0 0 45%;
|
|
453
622
|
font-size: 0.8rem;
|
|
454
623
|
}
|
|
455
|
-
|
|
624
|
+
|
|
456
625
|
.mobile-card-value {
|
|
457
626
|
font-size: 0.8rem;
|
|
458
627
|
}
|
|
459
|
-
|
|
628
|
+
|
|
460
629
|
.search-input {
|
|
461
630
|
margin-left: 8px;
|
|
462
631
|
margin-right: 8px;
|