@5minds/node-red-dashboard-2-processcube-dynamic-form 2.2.1-file-preview-2-247fbf-mfdx0ry5 → 2.2.1-file-preview-2-7f33a3-mfferizd

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.
@@ -79,6 +79,53 @@
79
79
  {{ field.customForm ? field.customForm.hint : undefined }}
80
80
  </p>
81
81
  </div>
82
+ <div v-else-if="createComponent(field).isFileField" class="ui-dynamic-form-file-wrapper">
83
+ <div v-if="getFilePreviewsForField(field.id).length > 0" class="ui-dynamic-form-image-previews">
84
+ <h4>Current files:</h4>
85
+ <div class="ui-dynamic-form-preview-grid">
86
+ <div
87
+ v-for="preview in getFilePreviewsForField(field.id)"
88
+ :key="preview.name"
89
+ class="ui-dynamic-form-preview-item"
90
+ >
91
+ <img
92
+ :src="preview.url"
93
+ :alt="preview.name"
94
+ class="ui-dynamic-form-preview-image"
95
+ @click="openImageModal(preview)"
96
+ />
97
+ <div class="ui-dynamic-form-preview-info">
98
+ <span class="ui-dynamic-form-preview-name">{{ preview.name }}</span>
99
+ <span class="ui-dynamic-form-preview-size">{{ formatFileSize(preview.size) }}</span>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <FormKit
106
+ type="file"
107
+ :id="field.id"
108
+ :name="createComponent(field).name"
109
+ :label="field.label"
110
+ :required="field.required"
111
+ v-model="formData[field.id]"
112
+ :help="createComponent(field).hint"
113
+ innerClass="reset-background"
114
+ wrapperClass="$remove:formkit-wrapper"
115
+ labelClass="ui-dynamic-form-input-label"
116
+ :inputClass="`input-${theme}`"
117
+ :readonly="createComponent(field).isReadOnly"
118
+ :disabled="createComponent(field).isReadOnly"
119
+ :multiple="createComponent(field).multiple"
120
+ :validation="createComponent(field).validation"
121
+ validationVisibility="live"
122
+ :ref="
123
+ (el) => {
124
+ if (index === 0) firstFormFieldRef = el;
125
+ }
126
+ "
127
+ />
128
+ </div>
82
129
  <component
83
130
  :is="createComponent(field).type"
84
131
  v-else
@@ -121,6 +168,27 @@
121
168
  <div v-if="!props.actions_inside_card && hasUserTask && actions.length > 0" style="padding-top: 32px">
122
169
  <UIDynamicFormFooterAction :actions="actions" :actionCallback="actionFn" />
123
170
  </div>
171
+
172
+ <v-dialog v-model="imageModalOpen" max-width="800px">
173
+ <v-card v-if="modalImage">
174
+ <v-card-title class="headline">{{ modalImage.name }}</v-card-title>
175
+ <v-card-text>
176
+ <img
177
+ :src="modalImage.url"
178
+ :alt="modalImage.name"
179
+ style="width: 100%; max-height: 70vh; object-fit: contain"
180
+ />
181
+ <div style="margin-top: 16px">
182
+ <strong>Size:</strong> {{ formatFileSize(modalImage.size) }}<br />
183
+ <strong>Type:</strong> {{ modalImage.type }}
184
+ </div>
185
+ </v-card-text>
186
+ <v-card-actions>
187
+ <v-spacer></v-spacer>
188
+ <v-btn color="primary" text @click="closeImageModal">Close</v-btn>
189
+ </v-card-actions>
190
+ </v-card>
191
+ </v-dialog>
124
192
  </div>
125
193
  </template>
126
194
 
@@ -215,6 +283,7 @@ export default {
215
283
  return {
216
284
  actions: [],
217
285
  formData: {},
286
+ originalFileData: {},
218
287
  userTask: null,
219
288
  theme: '',
220
289
  errorMsg: '',
@@ -222,6 +291,10 @@ export default {
222
291
  msg: null,
223
292
  collapsed: false,
224
293
  firstFormFieldRef: null,
294
+ imageModalOpen: false,
295
+ modalImage: null,
296
+ objectUrlsByField: {},
297
+ previewCache: {},
225
298
  };
226
299
  },
227
300
  computed: {
@@ -258,27 +331,55 @@ export default {
258
331
  watch: {
259
332
  formData: {
260
333
  handler(newData, oldData) {
334
+ if (!oldData) return;
335
+
336
+ if (this.userTask && this.userTask.userTaskConfig && this.userTask.userTaskConfig.formFields) {
337
+ const fileFields = this.userTask.userTaskConfig.formFields.filter((field) => field.type === 'file');
338
+
339
+ fileFields.forEach((field) => {
340
+ const fieldId = field.id;
341
+ const newValue = newData[fieldId];
342
+ const oldValue = oldData[fieldId];
343
+
344
+ if (newValue !== oldValue && this.originalFileData[fieldId]) {
345
+ const hasNewFiles = this.hasActualFileObjects(newValue);
346
+
347
+ if (hasNewFiles) {
348
+ delete this.originalFileData[fieldId];
349
+
350
+ Object.keys(this.previewCache).forEach((key) => {
351
+ if (key.startsWith(fieldId + '_')) {
352
+ delete this.previewCache[key];
353
+ }
354
+ });
355
+
356
+ this.cleanupObjectUrls(fieldId);
357
+ }
358
+ }
359
+ });
360
+ }
361
+
261
362
  if (this.props.trigger_on_change) {
262
363
  const res = { payload: { formData: newData, userTask: this.userTask } };
263
364
  this.send(res, this.totalOutputs - 1);
264
365
  }
265
366
  },
266
- collapsed(newVal) {
267
- if (!newVal && this.hasUserTask) {
268
- nextTick(() => {
269
- this.focusFirstFormField();
270
- });
271
- }
272
- },
273
- userTask(newVal) {
274
- if (newVal && !this.collapsed) {
275
- nextTick(() => {
276
- this.focusFirstFormField();
277
- });
278
- }
279
- },
280
367
  deep: true,
281
368
  },
369
+ collapsed(newVal) {
370
+ if (!newVal && this.hasUserTask) {
371
+ nextTick(() => {
372
+ this.focusFirstFormField();
373
+ });
374
+ }
375
+ },
376
+ userTask(newVal) {
377
+ if (newVal && !this.collapsed) {
378
+ nextTick(() => {
379
+ this.focusFirstFormField();
380
+ });
381
+ }
382
+ },
282
383
  },
283
384
  created() {
284
385
  const currentPath = window.location.pathname;
@@ -314,6 +415,7 @@ export default {
314
415
  },
315
416
  unmounted() {
316
417
  this.$socket?.off('msg-input:' + this.id);
418
+ this.cleanupObjectUrls();
317
419
  },
318
420
  methods: {
319
421
  createComponent(field) {
@@ -495,25 +597,17 @@ export default {
495
597
  case 'file':
496
598
  const multiple = field.customForm ? JSON.parse(JSON.stringify(field.customForm)).multiple === 'true' : false;
497
599
  return {
498
- type: 'FormKit',
600
+ type: 'div',
499
601
  props: {
500
- type: 'file',
501
- id: field.id,
502
- name,
503
- label: field.label,
504
- required: field.required,
505
- value: this.formData[field.id],
506
- help: hint,
507
- innerClass: 'reset-background',
508
- wrapperClass: '$remove:formkit-wrapper',
509
- labelClass: 'ui-dynamic-form-input-label',
510
- inputClass: `input-${this.theme}`,
511
- readonly: isReadOnly,
512
- disabled: isReadOnly,
513
- multiple,
514
- validation,
515
- validationVisibility: 'live',
602
+ class: 'ui-dynamic-form-file-wrapper',
516
603
  },
604
+ isFileField: true,
605
+ field: field,
606
+ multiple: multiple,
607
+ isReadOnly: isReadOnly,
608
+ hint: hint,
609
+ validation: validation,
610
+ name: name,
517
611
  };
518
612
  case 'checkbox':
519
613
  const options = JSON.parse(JSON.stringify(field.customForm)).entries.map((obj) => {
@@ -874,6 +968,9 @@ export default {
874
968
  return;
875
969
  }
876
970
 
971
+ this.originalFileData = {};
972
+ this.previewCache = {};
973
+ this.cleanupObjectUrls();
877
974
  this.actions = this.props.options;
878
975
 
879
976
  const hasTask = msg.payload && msg.payload.userTask;
@@ -883,6 +980,8 @@ export default {
883
980
  } else {
884
981
  this.userTask = null;
885
982
  this.formData = {};
983
+ this.originalFileData = {};
984
+ this.cleanupObjectUrls();
886
985
  return;
887
986
  }
888
987
 
@@ -972,7 +1071,12 @@ export default {
972
1071
  Object.keys(initialValues)
973
1072
  .filter((key) => formFieldIds.includes(key))
974
1073
  .forEach((key) => {
975
- this.formData[key] = initialValues[key];
1074
+ const field = formFields.find((f) => f.id === key);
1075
+ if (field && field.type === 'file') {
1076
+ this.formData[key] = this.transformBase64ToFormKitFormat(initialValues[key], field);
1077
+ } else {
1078
+ this.formData[key] = initialValues[key];
1079
+ }
976
1080
  });
977
1081
  }
978
1082
 
@@ -980,7 +1084,12 @@ export default {
980
1084
  Object.keys(finishedFormData)
981
1085
  .filter((key) => formFieldIds.includes(key))
982
1086
  .forEach((key) => {
983
- this.formData[key] = finishedFormData[key];
1087
+ const field = formFields.find((f) => f.id === key);
1088
+ if (field && field.type === 'file') {
1089
+ this.formData[key] = this.transformBase64ToFormKitFormat(finishedFormData[key], field);
1090
+ } else {
1091
+ this.formData[key] = finishedFormData[key];
1092
+ }
984
1093
  });
985
1094
  }
986
1095
 
@@ -1091,6 +1200,88 @@ export default {
1091
1200
  }
1092
1201
  }
1093
1202
  },
1203
+ /**
1204
+ * Transforms base64 file data to FormKit-compatible format for display.
1205
+ *
1206
+ * FormKit file inputs expect an array of objects with 'name' property for display.
1207
+ * This function converts the base64 format (with 'data' property) to the display format,
1208
+ * while preserving the original data in originalFileData for form submission.
1209
+ *
1210
+ * Expected input format from initialValues:
1211
+ * {
1212
+ * name: "filename.ext",
1213
+ * size: 12345,
1214
+ * type: "image/png",
1215
+ * data: "base64string..."
1216
+ * }
1217
+ *
1218
+ * Output format for FormKit:
1219
+ * [
1220
+ * { name: "filename.ext" }
1221
+ * ]
1222
+ *
1223
+ * @param {Object|Array} fileData - The file data from initial values
1224
+ * @param {Object} field - The form field configuration
1225
+ * @returns {Array} FormKit-compatible file array
1226
+ */
1227
+ transformBase64ToFormKitFormat(fileData, field) {
1228
+ if (
1229
+ Array.isArray(fileData) &&
1230
+ fileData.length > 0 &&
1231
+ typeof fileData[0] === 'object' &&
1232
+ fileData[0].name &&
1233
+ !fileData[0].data
1234
+ ) {
1235
+ return fileData;
1236
+ }
1237
+
1238
+ const fieldId = field.id;
1239
+ const multiple = field.customForm ? JSON.parse(JSON.stringify(field.customForm)).multiple === 'true' : false;
1240
+
1241
+ if (fileData && typeof fileData === 'object' && fileData.name && fileData.data) {
1242
+ this.originalFileData[fieldId] = fileData;
1243
+
1244
+ const formKitFile = {
1245
+ name: fileData.name,
1246
+ };
1247
+
1248
+ return multiple ? [formKitFile] : [formKitFile];
1249
+ }
1250
+
1251
+ if (Array.isArray(fileData)) {
1252
+ this.originalFileData[fieldId] = fileData;
1253
+
1254
+ const formKitFiles = fileData
1255
+ .filter((file) => file && typeof file === 'object' && file.name)
1256
+ .map((file) => ({
1257
+ name: file.name,
1258
+ }));
1259
+
1260
+ return formKitFiles;
1261
+ }
1262
+
1263
+ return [];
1264
+ },
1265
+ hasActualFileObjects(value) {
1266
+ if (!value) return false;
1267
+
1268
+ if (Array.isArray(value)) {
1269
+ return value.some((item) => {
1270
+ return (
1271
+ item instanceof File ||
1272
+ (item && item.file instanceof File) ||
1273
+ (item && item.file instanceof FileList && item.file.length > 0)
1274
+ );
1275
+ });
1276
+ }
1277
+
1278
+ return (
1279
+ value instanceof File ||
1280
+ (value instanceof FileList && value.length > 0) ||
1281
+ (value && value.file instanceof File) ||
1282
+ (value && value.file instanceof FileList && value.file.length > 0)
1283
+ );
1284
+ },
1094
1285
  async convertFileToBase64(file) {
1095
1286
  return new Promise((resolve, reject) => {
1096
1287
  const reader = new FileReader();
@@ -1114,6 +1305,13 @@ export default {
1114
1305
  for (const field of formFields) {
1115
1306
  if (field.type === 'file') {
1116
1307
  const fieldValue = processedData[field.id];
1308
+ const fieldId = field.id;
1309
+
1310
+ if (this.originalFileData[fieldId]) {
1311
+ processedData[fieldId] = this.originalFileData[fieldId];
1312
+ console.log(`Using original file data for field ${fieldId}:`, this.originalFileData[fieldId]);
1313
+ continue;
1314
+ }
1117
1315
 
1118
1316
  if (fieldValue) {
1119
1317
  const multiple = field.customForm
@@ -1121,7 +1319,6 @@ export default {
1121
1319
  : false;
1122
1320
 
1123
1321
  if (multiple && Array.isArray(fieldValue)) {
1124
- // Handle multiple files
1125
1322
  const base64Files = [];
1126
1323
  for (const file of fieldValue) {
1127
1324
  const processedFile = await this.processIndividualFile(file);
@@ -1184,6 +1381,158 @@ export default {
1184
1381
 
1185
1382
  return fileData;
1186
1383
  },
1384
+ isImageFile(fileData) {
1385
+ if (!fileData || typeof fileData !== 'object') return false;
1386
+
1387
+ const mimeType = fileData.type;
1388
+ if (!mimeType) return false;
1389
+
1390
+ return mimeType.startsWith('image/');
1391
+ },
1392
+ generateImagePreviewUrl(fileData) {
1393
+ if (!fileData || !fileData.data || !fileData.type) return null;
1394
+
1395
+ return `data:${fileData.type};base64,${fileData.data}`;
1396
+ },
1397
+ getFilePreviewsForField(fieldId) {
1398
+ const currentData = this.formData[fieldId] || null;
1399
+ const cachedOriginalData = this.originalFileData[fieldId] || null;
1400
+ const cacheKey = `${fieldId}_${JSON.stringify(currentData)}_${JSON.stringify(cachedOriginalData)}`;
1401
+
1402
+ if (this.previewCache[cacheKey]) {
1403
+ return this.previewCache[cacheKey];
1404
+ }
1405
+
1406
+ const currentFormData = this.formData[fieldId];
1407
+ const originalFileData = this.originalFileData[fieldId];
1408
+
1409
+ if (!currentFormData && !originalFileData) {
1410
+ this.previewCache[cacheKey] = [];
1411
+ return [];
1412
+ }
1413
+
1414
+ let previews = [];
1415
+
1416
+ const hasNewFileObjects =
1417
+ currentFormData &&
1418
+ Array.isArray(currentFormData) &&
1419
+ currentFormData.length > 0 &&
1420
+ this.hasActualFileObjects(currentFormData);
1421
+
1422
+ if (hasNewFileObjects) {
1423
+ previews = this.generatePreviewsFromCurrentFiles(currentFormData, fieldId);
1424
+ } else if (originalFileData) {
1425
+ const fileArray = Array.isArray(originalFileData) ? originalFileData : [originalFileData];
1426
+ previews = fileArray
1427
+ .filter((file) => this.isImageFile(file))
1428
+ .map((file) => ({
1429
+ name: file.name,
1430
+ url: this.generateImagePreviewUrl(file),
1431
+ size: file.size,
1432
+ type: file.type,
1433
+ }));
1434
+ }
1435
+
1436
+ this.previewCache[cacheKey] = previews;
1437
+
1438
+ return previews;
1439
+ },
1440
+ generatePreviewsFromCurrentFiles(fileArray, fieldId = null) {
1441
+ if (fieldId && this.objectUrlsByField[fieldId]) {
1442
+ this.objectUrlsByField[fieldId].forEach((url) => URL.revokeObjectURL(url));
1443
+ this.objectUrlsByField[fieldId] = [];
1444
+ } else if (fieldId) {
1445
+ this.objectUrlsByField[fieldId] = [];
1446
+ }
1447
+
1448
+ const previews = fileArray
1449
+ .filter((fileItem) => {
1450
+ let actualFile = null;
1451
+
1452
+ if (fileItem instanceof File) {
1453
+ actualFile = fileItem;
1454
+ } else if (fileItem && fileItem.file instanceof File) {
1455
+ actualFile = fileItem.file;
1456
+ } else if (Array.isArray(fileItem) && fileItem[0] instanceof File) {
1457
+ actualFile = fileItem[0];
1458
+ }
1459
+
1460
+ return actualFile && actualFile.type && actualFile.type.startsWith('image/');
1461
+ })
1462
+ .map((fileItem) => {
1463
+ let actualFile = null;
1464
+
1465
+ if (fileItem instanceof File) {
1466
+ actualFile = fileItem;
1467
+ } else if (fileItem && fileItem.file instanceof File) {
1468
+ actualFile = fileItem.file;
1469
+ } else if (Array.isArray(fileItem) && fileItem[0] instanceof File) {
1470
+ actualFile = fileItem[0];
1471
+ }
1472
+
1473
+ const url = actualFile ? URL.createObjectURL(actualFile) : null;
1474
+
1475
+ if (url && fieldId) {
1476
+ if (!this.objectUrlsByField[fieldId]) {
1477
+ this.objectUrlsByField[fieldId] = [];
1478
+ }
1479
+ this.objectUrlsByField[fieldId].push(url);
1480
+ }
1481
+
1482
+ return {
1483
+ name: actualFile.name,
1484
+ url: url,
1485
+ size: actualFile.size,
1486
+ type: actualFile.type,
1487
+ isObjectUrl: true,
1488
+ };
1489
+ })
1490
+ .filter((preview) => preview.url !== null);
1491
+
1492
+ return previews;
1493
+ },
1494
+ formatFileSize(bytes) {
1495
+ if (!bytes) return '0 B';
1496
+
1497
+ const k = 1024;
1498
+ const sizes = ['B', 'KB', 'MB', 'GB'];
1499
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1500
+
1501
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1502
+ },
1503
+ openImageModal(preview) {
1504
+ this.modalImage = preview;
1505
+ this.imageModalOpen = true;
1506
+ },
1507
+ closeImageModal() {
1508
+ this.imageModalOpen = false;
1509
+ this.modalImage = null;
1510
+ },
1511
+ cleanupObjectUrls(fieldId = null) {
1512
+ if (fieldId) {
1513
+ if (this.objectUrlsByField[fieldId]) {
1514
+ this.objectUrlsByField[fieldId].forEach((url) => {
1515
+ URL.revokeObjectURL(url);
1516
+ });
1517
+ this.objectUrlsByField[fieldId] = [];
1518
+ }
1519
+
1520
+ Object.keys(this.previewCache).forEach((key) => {
1521
+ if (key.startsWith(fieldId + '_')) {
1522
+ delete this.previewCache[key];
1523
+ }
1524
+ });
1525
+ } else {
1526
+ Object.keys(this.objectUrlsByField).forEach((fieldId) => {
1527
+ this.objectUrlsByField[fieldId].forEach((url) => {
1528
+ URL.revokeObjectURL(url);
1529
+ });
1530
+ });
1531
+ this.objectUrlsByField = {};
1532
+
1533
+ this.previewCache = {};
1534
+ }
1535
+ },
1187
1536
  },
1188
1537
  };
1189
1538
 
@@ -68,7 +68,7 @@ code {
68
68
  .ui-dynamic-form-title-default {
69
69
  background: linear-gradient(131deg, #18181a 26.76%, #242326 100.16%);
70
70
  padding: 32px;
71
- color: #ffffff;
71
+ color: #fff;
72
72
  font-size: 36px;
73
73
  font-weight: 700;
74
74
  border-radius: 8px 8px 0px 0px;
@@ -593,3 +593,111 @@ code {
593
593
  margin: revert;
594
594
  padding: revert;
595
595
  }
596
+
597
+ .ui-dynamic-form-image-previews {
598
+ margin-bottom: 16px;
599
+ padding: 16px;
600
+ border: 1px solid #e0e0e0;
601
+ border-radius: 8px;
602
+ background-color: #f9f9f9;
603
+ }
604
+
605
+ .ui-dynamic-form-image-previews h4 {
606
+ margin: 0 0 12px 0;
607
+ color: #333;
608
+ font-size: 14px;
609
+ font-weight: 600;
610
+ }
611
+
612
+ .ui-dynamic-form-preview-grid {
613
+ display: flex;
614
+ flex-wrap: wrap;
615
+ gap: 12px;
616
+ }
617
+
618
+ .ui-dynamic-form-preview-item {
619
+ display: flex;
620
+ flex-direction: column;
621
+ align-items: center;
622
+ padding: 8px;
623
+ border: 1px solid #ddd;
624
+ border-radius: 6px;
625
+ background-color: white;
626
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
627
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
628
+ max-width: 150px;
629
+ }
630
+
631
+ .ui-dynamic-form-preview-item:hover {
632
+ transform: translateY(-2px);
633
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
634
+ }
635
+
636
+ .ui-dynamic-form-preview-image {
637
+ width: 120px;
638
+ height: 80px;
639
+ object-fit: cover;
640
+ border-radius: 4px;
641
+ cursor: pointer;
642
+ border: 2px solid transparent;
643
+ transition: border-color 0.2s ease;
644
+ }
645
+
646
+ .ui-dynamic-form-preview-image:hover {
647
+ border-color: #1976d2;
648
+ }
649
+
650
+ .ui-dynamic-form-preview-info {
651
+ display: flex;
652
+ flex-direction: column;
653
+ align-items: center;
654
+ margin-top: 8px;
655
+ text-align: center;
656
+ }
657
+
658
+ .ui-dynamic-form-preview-name {
659
+ font-size: 12px;
660
+ font-weight: 500;
661
+ color: #333;
662
+ word-break: break-word;
663
+ max-width: 120px;
664
+ overflow: hidden;
665
+ text-overflow: ellipsis;
666
+ display: -webkit-box;
667
+ -webkit-line-clamp: 2;
668
+ line-clamp: 2;
669
+ -webkit-box-orient: vertical;
670
+ }
671
+
672
+ .ui-dynamic-form-preview-size {
673
+ font-size: 10px;
674
+ color: #666;
675
+ margin-top: 4px;
676
+ }
677
+
678
+ .ui-dynamic-form-file-wrapper {
679
+ width: 100%;
680
+ }
681
+
682
+ /* Dark theme adjustments */
683
+ .ui-dynamic-form-dark .ui-dynamic-form-image-previews {
684
+ background-color: #2d2d2d;
685
+ border-color: #555;
686
+ }
687
+
688
+ .ui-dynamic-form-dark .ui-dynamic-form-image-previews h4 {
689
+ color: #fff;
690
+ }
691
+
692
+ .ui-dynamic-form-dark .ui-dynamic-form-preview-item {
693
+ background-color: #3d3d3d;
694
+ border-color: #555;
695
+ }
696
+
697
+ .ui-dynamic-form-dark .ui-dynamic-form-preview-name {
698
+ color: #fff;
699
+ }
700
+
701
+ .ui-dynamic-form-dark .ui-dynamic-form-preview-size {
702
+ color: #ccc;
703
+ }