@sd-angular/core 19.0.46 → 19.0.47

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.
@@ -66,8 +66,34 @@ export declare class SdUploadFile<TArgs = any> {
66
66
  */
67
67
  getFiles: () => Promise<File[]>;
68
68
  /**
69
- * Thực hiện upload các file và gán giá trị, output trả ra mảng idOrKeys.
70
- * HÀM NÀY BẮT BUỘC PHẢI GỌI TRƯỚC KHI GỌI VỀ SERVER.
69
+ * Upload the local files referenced by the model, then replace their temporary
70
+ * keys with server IDs. Call and await this method before submitting the model.
71
+ *
72
+ * Files are read from the model and local snapshots/cache, so pending or failed metadata
73
+ * requests do not omit files. Each distinct local key is uploaded once per
74
+ * batch; duplicate occurrences retain their positions and share the returned ID.
75
+ * Server IDs and URLs are preserved even when they have no preview metadata.
76
+ *
77
+ * Concurrent calls share the active batch. Changes made while it is pending
78
+ * are preserved: removed keys stay removed, reordering is retained, and newly
79
+ * added local keys remain pending for a subsequent call. After destruction, no
80
+ * new upload starts and an in-flight upload cannot update the model.
81
+ *
82
+ * @returns A promise resolving to void after the batch finishes. The preview
83
+ * metadata refresh may still be pending. With no local files this is a no-op.
84
+ * A missing upload handler displays the configuration error and resolves
85
+ * without changing the model or cache.
86
+ * @throws The upload handler's error, without changing the model or cache.
87
+ * @throws Error if the handler does not return exactly one non-empty string ID
88
+ * per distinct local key; the model and cache are retained in that case.
89
+ *
90
+ * @example
91
+ * import { SdUploadFile } from '@sd-angular/core/components/upload-file';
92
+ *
93
+ * async function submit(control: SdUploadFile) {
94
+ * await control.upload();
95
+ * return control.model(); // Server IDs/URLs for the uploaded batch.
96
+ * }
71
97
  */
72
98
  upload: () => Promise<void>;
73
99
  /**
@@ -199,6 +199,9 @@ class SdUploadFile {
199
199
  #canvas2 = `C${Utilities.generateUuid()}`;
200
200
  #name = Utilities.generateUuid();
201
201
  #formGroup;
202
+ #uploadPromise;
203
+ #destroyed = false;
204
+ #localFiles = new Map();
202
205
  formControl = new SdFormControl();
203
206
  // why: component là OnPush nhưng template đọc formControl.touched/errors như thuộc tính
204
207
  // THƯỜNG (không reactive). Khi cha submit (markAllAsTouched) hoặc validator gắn lỗi, không
@@ -324,19 +327,34 @@ class SdUploadFile {
324
327
  }
325
328
  });
326
329
  // Reactive sync for model changes
327
- effect(() => {
328
- const values = this.model() || [];
330
+ effect(onCleanup => {
331
+ const modelValue = this.model();
332
+ const values = modelValue || [];
333
+ let active = true;
334
+ let cancelReaders;
335
+ // why: model có thể đổi trước lần effect kế tiếp; cleanup thôi chưa chặn được
336
+ // Promise cũ resolve trong khoảng đó. Destroy cũng phải chặn mọi emit/ghi state.
337
+ const isCurrent = () => active && !this.#destroyed && this.model() === modelValue;
338
+ onCleanup(() => {
339
+ active = false;
340
+ cancelReaders?.();
341
+ });
329
342
  this.formControl.setValue(values);
330
343
  if (Array.isArray(values)) {
331
344
  this.#details(values).then(previewFiles => {
345
+ if (!isCurrent())
346
+ return;
332
347
  this.previewFiles.set(previewFiles);
333
348
  this.filesChanged.emit(this.previewFiles()
334
349
  .map(e => e.file || e.idOrKey || e.src)
335
350
  .filter(val => !!val));
336
- this.#preview();
351
+ // why: consumer của filesChanged có thể đổi model hoặc destroy đồng bộ.
352
+ if (isCurrent())
353
+ cancelReaders = this.#preview(isCurrent);
337
354
  });
338
355
  }
339
356
  else {
357
+ this.#localFiles.clear();
340
358
  this.previewFiles.set([]);
341
359
  }
342
360
  });
@@ -364,6 +382,8 @@ class SdUploadFile {
364
382
  });
365
383
  // Cleanup on destroy
366
384
  this.#destroyRef.onDestroy(() => {
385
+ this.#destroyed = true;
386
+ this.#localFiles.clear();
367
387
  this.#revokePreviewBlobURLs();
368
388
  this.#formGroup?.removeControl(this.#name);
369
389
  });
@@ -495,17 +515,28 @@ class SdUploadFile {
495
515
  }
496
516
  return null;
497
517
  };
498
- #preview = () => {
518
+ #preview = (isCurrent) => {
519
+ const readers = [];
499
520
  for (const previewFile of this.previewFiles().filter(e => e.file)) {
500
521
  const reader = new FileReader();
501
522
  reader.onload = evt => {
523
+ if (!isCurrent())
524
+ return;
502
525
  previewFile.previewSrc = evt.target.result;
503
526
  previewFile.isPreviewImage = previewFile.file?.type.split('/')[0] === 'image' && previewFile.file?.type !== 'image/tiff';
504
527
  // trigger signal update
505
528
  this.previewFiles.set([...this.previewFiles()]);
506
529
  };
530
+ readers.push(reader);
507
531
  reader.readAsDataURL(previewFile.file);
508
532
  }
533
+ return () => {
534
+ for (const reader of readers) {
535
+ reader.onload = null;
536
+ if (reader.readyState === FileReader.LOADING)
537
+ reader.abort();
538
+ }
539
+ };
509
540
  };
510
541
  // ─── Public Event Handlers ────────────────────────────────────────────
511
542
  onUpload = () => {
@@ -535,6 +566,7 @@ class SdUploadFile {
535
566
  };
536
567
  #uploadFile = async (originFiles) => {
537
568
  const currentModel = [...(this.model() || [])];
569
+ const pickedFiles = new Map();
538
570
  if (originFiles.length + currentModel.length > this.max()) {
539
571
  this.#notifyService.warning(this.#i18n.t('core.component.upload-file.too-many-files'));
540
572
  return;
@@ -551,9 +583,15 @@ class SdUploadFile {
551
583
  }
552
584
  const hashedKey = this.#uploadFileService.add(file);
553
585
  if (hashedKey) {
586
+ pickedFiles.set(hashedKey, file);
554
587
  currentModel.push(hashedKey);
555
588
  }
556
589
  }
590
+ // why: giữ batch riêng qua từng await validate/resize; cache chung và effect của
591
+ // model cũ có thể mất hash trước khi picker hoàn tất. Chỉ nhận ownership khi commit.
592
+ for (const [key, file] of pickedFiles)
593
+ this.#localFiles.set(key, file);
594
+ this.#snapshotFiles(currentModel);
557
595
  this.model.set(currentModel);
558
596
  this.#touch();
559
597
  };
@@ -685,50 +723,81 @@ class SdUploadFile {
685
723
  return items.map(e => e?.file).filter(file => !!file);
686
724
  };
687
725
  /**
688
- * Thực hiện upload các file và gán giá trị, output trả ra mảng idOrKeys.
689
- * HÀM NÀY BẮT BUỘC PHẢI GỌI TRƯỚC KHI GỌI VỀ SERVER.
726
+ * Upload the local files referenced by the model, then replace their temporary
727
+ * keys with server IDs. Call and await this method before submitting the model.
728
+ *
729
+ * Files are read from the model and local snapshots/cache, so pending or failed metadata
730
+ * requests do not omit files. Each distinct local key is uploaded once per
731
+ * batch; duplicate occurrences retain their positions and share the returned ID.
732
+ * Server IDs and URLs are preserved even when they have no preview metadata.
733
+ *
734
+ * Concurrent calls share the active batch. Changes made while it is pending
735
+ * are preserved: removed keys stay removed, reordering is retained, and newly
736
+ * added local keys remain pending for a subsequent call. After destruction, no
737
+ * new upload starts and an in-flight upload cannot update the model.
738
+ *
739
+ * @returns A promise resolving to void after the batch finishes. The preview
740
+ * metadata refresh may still be pending. With no local files this is a no-op.
741
+ * A missing upload handler displays the configuration error and resolves
742
+ * without changing the model or cache.
743
+ * @throws The upload handler's error, without changing the model or cache.
744
+ * @throws Error if the handler does not return exactly one non-empty string ID
745
+ * per distinct local key; the model and cache are retained in that case.
746
+ *
747
+ * @example
748
+ * import { SdUploadFile } from '@sd-angular/core/components/upload-file';
749
+ *
750
+ * async function submit(control: SdUploadFile) {
751
+ * await control.upload();
752
+ * return control.model(); // Server IDs/URLs for the uploaded batch.
753
+ * }
690
754
  */
691
- upload = async () => {
692
- const items = this.previewFiles()?.filter(val => !!val) || [];
693
- if (!Array.isArray(items)) {
694
- this.model.set([]);
755
+ upload = () => {
756
+ if (this.#destroyed)
757
+ return Promise.resolve();
758
+ if (!this.#uploadPromise) {
759
+ // why: hai lần submit đồng thời phải dùng chung batch, tránh upload trùng File.
760
+ this.#uploadPromise = this.#uploadPendingFiles().finally(() => {
761
+ this.#uploadPromise = undefined;
762
+ });
763
+ }
764
+ return this.#uploadPromise;
765
+ };
766
+ #uploadPendingFiles = async () => {
767
+ const pending = [
768
+ ...new Map(this.#snapshotFiles(this.model())
769
+ .filter(entry => entry.file)
770
+ .map(entry => [entry.key, entry])).values(),
771
+ ];
772
+ if (!pending.length)
773
+ return;
774
+ const uploadHandler = this.#getUploadHandler();
775
+ if (!uploadHandler) {
776
+ const key = this.key();
777
+ this.#notifyService.error(key === undefined
778
+ ? this.#i18n.t('core.component.upload-file.no-configuration')
779
+ : this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
695
780
  return;
696
781
  }
697
- const results = [];
698
- const uploadedFiles = items.map(e => e?.file).filter(file => !!file);
699
- let idOrKeys = [];
700
- if (uploadedFiles.length) {
701
- const uploadHandler = this.#getUploadHandler();
702
- if (uploadHandler) {
703
- idOrKeys = await uploadHandler(uploadedFiles, this.args()).then(results => {
704
- for (const uploadedFile of uploadedFiles) {
705
- this.#uploadFileService.remove(uploadedFile);
706
- }
707
- return results;
708
- });
709
- }
710
- else {
711
- const key = this.key();
712
- this.#notifyService.error(key === undefined
713
- ? this.#i18n.t('core.component.upload-file.no-configuration')
714
- : this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
715
- }
782
+ const idOrKeys = await uploadHandler(pending.map(entry => entry.file), this.args());
783
+ if (!Array.isArray(idOrKeys) ||
784
+ idOrKeys.length !== pending.length ||
785
+ pending.some((_, index) => typeof idOrKeys[index] !== 'string' || !idOrKeys[index])) {
786
+ throw new Error('[sd-upload-file] Upload must return one non-empty string ID per file.');
716
787
  }
717
- let idx = 0;
718
- for (const item of items) {
719
- if (!item.file) {
720
- if (item.idOrKey) {
721
- results.push(item.idOrKey);
722
- }
723
- }
724
- else {
725
- if (idOrKeys?.[idx]) {
726
- results.push(idOrKeys[idx]);
727
- idx++;
728
- }
729
- }
788
+ const replacements = new Map(pending.map((entry, index) => [entry.key, idOrKeys[index]]));
789
+ for (const entry of pending) {
790
+ this.#uploadFileService.remove(entry.key.toString());
791
+ this.#localFiles.delete(entry.key.toString());
792
+ }
793
+ if (this.#destroyed)
794
+ return;
795
+ // why: thay đúng hash trên model MỚI NHẤT, không phục hồi file đã xoá hoặc
796
+ // ghi đè các thao tác thêm/sắp xếp trong lúc upload đang chờ.
797
+ const values = this.model();
798
+ if (Array.isArray(values)) {
799
+ this.model.set(values.map(key => replacements.get(key) ?? key));
730
800
  }
731
- this.model.set(results);
732
801
  };
733
802
  /**
734
803
  * Mở popup preview ảnh.
@@ -771,20 +840,23 @@ class SdUploadFile {
771
840
  * cần render ra ảnh/tệp tương ứng.
772
841
  */
773
842
  #details = async (keys) => {
774
- if (!Array.isArray(keys) || !keys.length) {
843
+ const entries = this.#snapshotFiles(keys);
844
+ if (!entries.length) {
775
845
  return [];
776
846
  }
777
- const idOrKeys = keys.filter(key => !!key && !this.#isCdn(key) && !this.#isHashedKey(key));
847
+ const idOrKeys = entries.filter(entry => !this.#isCdn(entry.key) && !entry.file).map(entry => entry.key);
778
848
  let detailsList = [];
779
849
  const detailsHandler = this.#getDetailsHandler();
780
850
  if (idOrKeys.length && detailsHandler) {
781
- detailsList = await detailsHandler(idOrKeys, this.args()).catch(error => {
851
+ try {
852
+ detailsList = await detailsHandler(idOrKeys, this.args());
853
+ }
854
+ catch (error) {
782
855
  console.error(error);
783
- return [];
784
- });
856
+ }
785
857
  }
786
858
  const results = [];
787
- for (const key of keys.filter(val => !!val)) {
859
+ for (const { key, file } of entries) {
788
860
  if (this.#isCdn(key)) {
789
861
  const cdn = key?.toString();
790
862
  const extension = this.#getExtension(cdn);
@@ -799,9 +871,7 @@ class SdUploadFile {
799
871
  extension,
800
872
  });
801
873
  }
802
- else if (this.#isHashedKey(key)) {
803
- const hashedKey = key?.toString();
804
- const file = this.#uploadFileService.get(hashedKey);
874
+ else if (file) {
805
875
  const lastDot = file.name.lastIndexOf('.');
806
876
  const extension = file.name.substring(lastDot + 1);
807
877
  const isPreviewImage = (file.type.split('/')[0] === 'image' && file.type !== 'image/tiff') || IsImage(extension);
@@ -842,10 +912,26 @@ class SdUploadFile {
842
912
  return false;
843
913
  return idOrKey.startsWith('http');
844
914
  };
845
- #isHashedKey = (idOrKey) => {
846
- if (typeof idOrKey !== 'string')
847
- return false;
848
- return this.#uploadFileService.isHashedKey(idOrKey);
915
+ #snapshotFiles = (keys) => {
916
+ const values = Array.isArray(keys) ? keys : [];
917
+ const activeKeys = new Set(values);
918
+ for (const key of this.#localFiles.keys()) {
919
+ if (!activeKeys.has(key))
920
+ this.#localFiles.delete(key);
921
+ }
922
+ // why: cache có thể bị upload xoá trong lúc chờ details; giữ File theo snapshot
923
+ // của từng ô và request, kể cả khi ô khác upload cùng hash rồi xoá cache chung.
924
+ return values
925
+ .filter(key => !!key)
926
+ .map(key => {
927
+ let file = null;
928
+ if (typeof key === 'string' && !this.#isCdn(key)) {
929
+ file = this.#localFiles.get(key) ?? this.#uploadFileService.get(key);
930
+ if (file)
931
+ this.#localFiles.set(key, file);
932
+ }
933
+ return { key, file };
934
+ });
849
935
  };
850
936
  #getExtension = (cdn) => {
851
937
  if (!cdn)