@salesforce/webapp-template-feature-react-file-upload-experimental 1.90.0 → 1.90.2

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.
Files changed (45) hide show
  1. package/README.md +245 -219
  2. package/dist/CHANGELOG.md +16 -0
  3. package/dist/force-app/main/default/webapplications/feature-react-file-upload/package.json +3 -3
  4. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{api → features/fileupload/api}/fileUpload.ts +153 -0
  5. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{components → features/fileupload/components}/FileUploadDialog.tsx +2 -2
  6. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{components → features/fileupload/components}/FileUploadFileItem.tsx +1 -1
  7. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +25 -39
  8. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +2 -2
  9. package/dist/package.json +1 -1
  10. package/package.json +14 -9
  11. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/appLayout.tsx +9 -0
  12. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/__inherit__button.tsx +39 -0
  13. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/__inherit__dialog.tsx +102 -0
  14. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/api/fileUpload.ts +299 -0
  15. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/assets/icon-image-close.svg +3 -0
  16. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/assets/image.svg +3 -0
  17. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/assets/success.svg +3 -0
  18. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/assets/symbols.svg +1 -0
  19. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/components/FileUpload.tsx +100 -0
  20. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/components/FileUploadDialog.tsx +79 -0
  21. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/components/FileUploadDropZone.tsx +90 -0
  22. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/components/FileUploadFileItem.tsx +99 -0
  23. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/components/FileUploadIcons.tsx +90 -0
  24. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/hooks/useFileUpload.ts +312 -0
  25. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/hooks/useFileUploadDialog.ts +70 -0
  26. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/pages/UploadTest.tsx +56 -0
  27. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/types/fileUpload.ts +28 -0
  28. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/utils/fileUploadUtils.ts +54 -0
  29. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/features/fileupload/utils/labels.ts +23 -0
  30. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +60 -0
  31. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +22 -0
  32. package/src/force-app/main/default/webapplications/feature-react-file-upload/vite.config.ts +43 -0
  33. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{assets → features/fileupload/assets}/icon-image-close.svg +0 -0
  34. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{assets → features/fileupload/assets}/image.svg +0 -0
  35. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{assets → features/fileupload/assets}/success.svg +0 -0
  36. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{assets → features/fileupload/assets}/symbols.svg +0 -0
  37. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{components → features/fileupload/components}/FileUpload.tsx +0 -0
  38. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{components → features/fileupload/components}/FileUploadDropZone.tsx +0 -0
  39. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{components → features/fileupload/components}/FileUploadIcons.tsx +0 -0
  40. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{hooks → features/fileupload/hooks}/useFileUpload.ts +0 -0
  41. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{hooks → features/fileupload/hooks}/useFileUploadDialog.ts +0 -0
  42. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{pages → features/fileupload/pages}/UploadTest.tsx +0 -0
  43. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{types → features/fileupload/types}/fileUpload.ts +0 -0
  44. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{utils → features/fileupload/utils}/fileUploadUtils.ts +0 -0
  45. /package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/{utils → features/fileupload/utils}/labels.ts +0 -0
@@ -0,0 +1,90 @@
1
+ import { LABELS } from "../utils/labels";
2
+
3
+ import { ImageIcon } from "./FileUploadIcons";
4
+
5
+ export interface FileUploadDropZoneProps {
6
+ /** Props for the hidden file input (ref, type, accept, multiple, onChange) */
7
+ inputProps: {
8
+ ref: React.RefObject<HTMLInputElement | null>;
9
+ type: "file";
10
+ accept?: string;
11
+ multiple: boolean;
12
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
13
+ };
14
+ /** Props for the drop zone (onClick, onDragOver, onDragLeave, onDrop, onKeyDown) */
15
+ dropZoneProps: {
16
+ onClick: () => void;
17
+ onDragOver: (e: React.DragEvent) => void;
18
+ onDragLeave: (e: React.DragEvent) => void;
19
+ onDrop: (e: React.DragEvent) => void;
20
+ onKeyDown: (e: React.KeyboardEvent) => void;
21
+ };
22
+ /** Whether the user is currently dragging over the drop zone */
23
+ isDragging: boolean;
24
+ /** Optional format hint (e.g. "JPEG, PNG, PDF, and MP4 formats, up to 50MB"). Defaults to LABELS.formatHint. */
25
+ formatHint?: string;
26
+ /** Optional CSS class for the drop zone (e.g. "h-full" for flex layouts) */
27
+ className?: string;
28
+ }
29
+
30
+ const DROP_ZONE_BASE_CLASSES =
31
+ "mb-2 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed bg-white px-6 pt-4 pb-8 transition-colors";
32
+ const DROP_ZONE_DRAGGING_CLASSES = "border-blue-500 bg-blue-50";
33
+ const DROP_ZONE_IDLE_CLASSES = "border-gray-300 hover:border-gray-400 hover:bg-gray-50";
34
+
35
+ /**
36
+ * Drop zone for file selection. Renders a dashed border area with document icon,
37
+ * primary instruction text, and format hint. Accepts click and drag-and-drop.
38
+ * Uses a hidden file input; props come from useFileUpload.
39
+ */
40
+ export function FileUploadDropZone({
41
+ inputProps,
42
+ dropZoneProps,
43
+ isDragging,
44
+ formatHint = LABELS.formatHint,
45
+ className = "",
46
+ }: FileUploadDropZoneProps) {
47
+ const dropZoneClassName = [
48
+ DROP_ZONE_BASE_CLASSES,
49
+ isDragging ? DROP_ZONE_DRAGGING_CLASSES : DROP_ZONE_IDLE_CLASSES,
50
+ className,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" ");
54
+
55
+ return (
56
+ <div
57
+ role="button"
58
+ tabIndex={0}
59
+ onClick={dropZoneProps.onClick}
60
+ onDragOver={dropZoneProps.onDragOver}
61
+ onDragLeave={dropZoneProps.onDragLeave}
62
+ onDrop={dropZoneProps.onDrop}
63
+ onKeyDown={dropZoneProps.onKeyDown}
64
+ className={dropZoneClassName}
65
+ aria-label={LABELS.dropZone}
66
+ data-testid="file-upload-drop-zone"
67
+ >
68
+ <input
69
+ id="file-upload-input-id"
70
+ ref={inputProps.ref}
71
+ type="file"
72
+ accept={inputProps.accept}
73
+ multiple={inputProps.multiple}
74
+ onChange={inputProps.onChange}
75
+ className="sr-only"
76
+ aria-hidden
77
+ />
78
+ <span
79
+ className="inline-flex h-12 w-12 shrink-0 items-center justify-center text-gray-500"
80
+ aria-hidden
81
+ >
82
+ <ImageIcon size="lg" />
83
+ </span>
84
+ <p className="text-center text-sm font-medium text-gray-900">
85
+ {isDragging ? LABELS.dropFilesHere : LABELS.chooseFileOrDrop}
86
+ </p>
87
+ <p className="text-center text-xs text-gray-500">{formatHint}</p>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,99 @@
1
+ import { Button } from "../../../components/ui/__inherit__button";
2
+ import { FileTypeIcon, IconImageClose, SuccessIcon } from "./FileUploadIcons";
3
+ import {
4
+ formatFileSize,
5
+ getFileExtension,
6
+ getProgressWidth,
7
+ isUploading,
8
+ } from "../utils/fileUploadUtils";
9
+ import { LABELS } from "../utils/labels";
10
+ import type { FileUploadItem } from "../types/fileUpload";
11
+
12
+ export interface FileUploadFileItemProps {
13
+ /** The file item with upload state, progress, and optional error */
14
+ item: FileUploadItem;
15
+ /** Called when the user cancels an in-progress upload */
16
+ onCancel: (fileName: string) => void;
17
+ }
18
+
19
+ /**
20
+ * Renders a single file upload item with file type icon, name, size, progress bar,
21
+ * cancel button (when uploading), and status icon (success, error, or cancelled).
22
+ */
23
+ export function FileUploadFileItem({ item, onCancel }: FileUploadFileItemProps) {
24
+ const extension = getFileExtension(item.file.name);
25
+ const progressWidth = getProgressWidth(item.state, item.progress);
26
+
27
+ return (
28
+ <li
29
+ className="grid min-w-0 grid-cols-[auto_minmax(0,4fr)_minmax(0,5fr)] items-center gap-2 rounded-lg border border-gray-200 bg-gray-100 p-2"
30
+ data-testid="file-upload-item"
31
+ >
32
+ <FileTypeIcon extension={extension} />
33
+ <div className="min-w-0 overflow-hidden">
34
+ <p
35
+ className="truncate text-sm font-medium text-gray-900"
36
+ title={item.file.name}
37
+ aria-label={LABELS.fileName(item.file.name)}
38
+ >
39
+ {item.file.name}
40
+ </p>
41
+ <p
42
+ className="truncate text-xs text-gray-600"
43
+ aria-label={LABELS.fileSize(formatFileSize(item.file.size))}
44
+ >
45
+ {formatFileSize(item.file.size)}
46
+ </p>
47
+ </div>
48
+ <div className="flex min-w-0 items-center gap-1.5">
49
+ <div
50
+ className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-200"
51
+ role="progressbar"
52
+ aria-label={LABELS.uploadProgress(item.file.name)}
53
+ aria-valuenow={progressWidth}
54
+ aria-valuemin={0}
55
+ aria-valuemax={100}
56
+ >
57
+ <div
58
+ className="h-full bg-blue-600 transition-all duration-300"
59
+ style={{ width: `${progressWidth}%` }}
60
+ />
61
+ </div>
62
+ {isUploading(item.state) && (
63
+ <Button
64
+ type="button"
65
+ variant="ghost"
66
+ size="icon"
67
+ className="h-7 w-7 shrink-0 text-gray-600 hover:text-red-600"
68
+ onClick={() => onCancel(item.file.name)}
69
+ aria-label={LABELS.cancelUpload(item.file.name)}
70
+ >
71
+ <IconImageClose fill="rgb(116, 116, 116)" />
72
+ </Button>
73
+ )}
74
+ {renderStatusIcon(item.state)}
75
+ {item.state === "error" && item.error && <p className="sr-only">{item.error}</p>}
76
+ </div>
77
+ </li>
78
+ );
79
+ }
80
+
81
+ function renderStatusIcon(state: FileUploadItem["state"]): React.ReactNode {
82
+ if (isUploading(state)) return null;
83
+ if (state === "success") return <SuccessIcon fill="rgb(46, 132, 74)" />;
84
+ if (state === "cancelled") {
85
+ return (
86
+ <span className="shrink-0 text-xs text-gray-500" aria-label={LABELS.cancelled}>
87
+ {LABELS.cancelled}
88
+ </span>
89
+ );
90
+ }
91
+ return (
92
+ <span
93
+ className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-red-600"
94
+ aria-label={LABELS.uploadFailed}
95
+ >
96
+ ×
97
+ </span>
98
+ );
99
+ }
@@ -0,0 +1,90 @@
1
+ import symbolsUrl from "../assets/symbols.svg?url";
2
+
3
+ const IMAGE_EXTENSIONS = new Set([
4
+ "jpg",
5
+ "jpeg",
6
+ "png",
7
+ "gif",
8
+ "webp",
9
+ "svg",
10
+ "bmp",
11
+ "ico",
12
+ "tiff",
13
+ "tif",
14
+ ]);
15
+
16
+ /**
17
+ * Renders a file-type icon based on extension. Uses symbols.svg: "unknown" for
18
+ * no extension, "image" for image types, or the extension (e.g. pdf) for others.
19
+ */
20
+ export function FileTypeIcon({ extension }: { extension: string }) {
21
+ /** Symbol ID in symbols.svg: unknown (no ext), image (image types), or extension (e.g. pdf). */
22
+ const ext = extension.toLowerCase();
23
+ const symbolId = extension === "FILE" ? "unknown" : IMAGE_EXTENSIONS.has(ext) ? "image" : ext;
24
+ return (
25
+ <span
26
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center"
27
+ aria-hidden
28
+ title={extension}
29
+ >
30
+ <svg className="h-full w-full" aria-hidden>
31
+ <use href={`${symbolsUrl}#${symbolId}`} />
32
+ </svg>
33
+ </span>
34
+ );
35
+ }
36
+
37
+ import iconImageCloseSvg from "../assets/icon-image-close.svg?raw";
38
+ import successSvg from "../assets/success.svg?raw";
39
+ import imageSvg from "../assets/image.svg?raw";
40
+
41
+ /** Props for icon components (close, success, image) */
42
+ export interface IconProps {
43
+ size?: "sm" | "md" | "lg";
44
+ fill?: string;
45
+ }
46
+
47
+ const sizeClass = (size: IconProps["size"]) =>
48
+ size === "sm" ? "h-4 w-4" : size === "lg" ? "h-12 w-12" : "h-6 w-6";
49
+
50
+ /**
51
+ * Close icon (circle with X). Renders from icon-image-close.svg.
52
+ */
53
+ export function IconImageClose({ size = "md", fill = "currentColor" }: IconProps) {
54
+ return (
55
+ <span
56
+ className={`inline-flex ${sizeClass(size)} shrink-0 items-center justify-center`}
57
+ style={{ color: fill }}
58
+ aria-hidden
59
+ dangerouslySetInnerHTML={{ __html: iconImageCloseSvg }}
60
+ />
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Success icon (circle with checkmark). Renders from success.svg.
66
+ */
67
+ export function SuccessIcon({ size = "md", fill = "currentColor" }: IconProps) {
68
+ return (
69
+ <span
70
+ className={`inline-flex ${sizeClass(size)} shrink-0 items-center justify-center`}
71
+ style={{ color: fill }}
72
+ aria-hidden
73
+ dangerouslySetInnerHTML={{ __html: successSvg }}
74
+ />
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Image icon (picture frame). Renders from image.svg. Used for drop zone.
80
+ */
81
+ export function ImageIcon({ size = "lg", fill = "currentColor" }: IconProps) {
82
+ return (
83
+ <span
84
+ className={`inline-flex ${sizeClass(size)} shrink-0 items-center justify-center`}
85
+ style={{ color: fill }}
86
+ aria-hidden
87
+ dangerouslySetInnerHTML={{ __html: imageSvg }}
88
+ />
89
+ );
90
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Hook: useFileUpload
3
+ *
4
+ * Manages file upload state and logic: config fetch, upload to URL, ContentVersion creation.
5
+ * Separates upload logic from UI. Use for custom upload UIs or with the FileUpload component.
6
+ *
7
+ * @param options - Upload options (accept, multiple, recordId, onUploadComplete, onUploadError)
8
+ * @returns Object containing fileItems, allDone, cancelFile, getInputProps, getDropZoneProps, openFilePicker, isDragging, reset
9
+ *
10
+ * @remarks
11
+ * Coordinates: (1) getUploadConfig for token/URL, (2) uploadToUrl for file body, (3) createContentVersion
12
+ * for record creation. Supports cancel, progress tracking, and sequential multi-file uploads.
13
+ */
14
+ import * as React from "react";
15
+ import { flushSync } from "react-dom";
16
+ import { getUploadConfig, uploadToUrl, createContentVersion } from "../api/fileUpload";
17
+ import type { FileUploadItem, UploadedFile, UploadState } from "../types/fileUpload";
18
+ import {
19
+ isFileTooLarge,
20
+ MAX_FILE_SIZE_BYTES,
21
+ bytesFromMB,
22
+ formatFileSize,
23
+ } from "../utils/fileUploadUtils";
24
+ import { LABELS } from "../utils/labels";
25
+
26
+ export interface UseFileUploadOptions {
27
+ /** MIME types or file extensions to accept (e.g. image/*, .pdf). Omit for all files. */
28
+ accept?: string;
29
+ /** Whether to allow multiple file selection. Default: false */
30
+ multiple?: boolean;
31
+ /** Record Id for FirstPublishLocationId (e.g. Account, Opportunity). When provided, files are linked to this record and ContentVersion is created. When null/undefined, only uploads file and returns contentBodyId without creating ContentVersion. */
32
+ recordId?: string;
33
+ /** Called when uploads complete. Receives array of successfully uploaded files with name, size, contentBodyId, and contentVersionId (if ContentVersion was created). */
34
+ onUploadComplete?: (files: UploadedFile[]) => void;
35
+ /** Called when an upload fails. Receives the file and error message. */
36
+ onUploadError?: (file: File, error: string) => void;
37
+ /** Maximum file size in MB. Files exceeding this limit are rejected with an error. Omit for default (2 GB). */
38
+ maxFileSize?: number;
39
+ }
40
+
41
+ function updateItem(
42
+ items: FileUploadItem[],
43
+ fileName: string,
44
+ update: Partial<FileUploadItem>,
45
+ ): FileUploadItem[] {
46
+ return items.map((item) => (item.file.name === fileName ? { ...item, ...update } : item));
47
+ }
48
+
49
+ interface UseFileUploadReturn {
50
+ fileItems: FileUploadItem[];
51
+ allDone: boolean;
52
+ cancelFile: (fileName: string) => void;
53
+ getInputProps: () => {
54
+ ref: React.RefObject<HTMLInputElement | null>;
55
+ type: "file";
56
+ accept?: string;
57
+ multiple: boolean;
58
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
59
+ };
60
+ getDropZoneProps: () => {
61
+ onClick: () => void;
62
+ onDragOver: (e: React.DragEvent) => void;
63
+ onDragLeave: (e: React.DragEvent) => void;
64
+ onDrop: (e: React.DragEvent) => void;
65
+ onKeyDown: (e: React.KeyboardEvent) => void;
66
+ };
67
+ /** Programmatically open the native file picker. Use with a custom trigger (e.g. button). */
68
+ openFilePicker: () => void;
69
+ isDragging: boolean;
70
+ reset: () => void;
71
+ }
72
+
73
+ export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {
74
+ const {
75
+ accept,
76
+ multiple = false,
77
+ recordId,
78
+ onUploadComplete,
79
+ onUploadError,
80
+ maxFileSize,
81
+ } = options;
82
+
83
+ const maxBytes = maxFileSize != null ? bytesFromMB(maxFileSize) : MAX_FILE_SIZE_BYTES;
84
+
85
+ const [fileItems, setFileItems] = React.useState<FileUploadItem[]>([]);
86
+ const [isDragging, setIsDragging] = React.useState(false);
87
+ const inputRef = React.useRef<HTMLInputElement>(null);
88
+ const cancelledFilesRef = React.useRef<Set<string>>(new Set());
89
+ const currentAbortControllerRef = React.useRef<AbortController | null>(null);
90
+ const currentFileNameRef = React.useRef<string | null>(null);
91
+
92
+ const cancelFile = React.useCallback((fileName: string) => {
93
+ cancelledFilesRef.current.add(fileName);
94
+ if (currentFileNameRef.current === fileName && currentAbortControllerRef.current) {
95
+ currentAbortControllerRef.current.abort();
96
+ }
97
+ }, []);
98
+
99
+ React.useEffect(() => {
100
+ return () => {
101
+ currentAbortControllerRef.current?.abort();
102
+ };
103
+ }, []);
104
+
105
+ const handleChange = React.useCallback(
106
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
107
+ const files = e.target.files ? Array.from(e.target.files) : [];
108
+ if (files.length === 0) return;
109
+
110
+ const maxSizeLabel = formatFileSize(maxBytes);
111
+ const errorMessage = LABELS.fileTooLarge(maxSizeLabel);
112
+
113
+ const items: FileUploadItem[] = files.map((file) => {
114
+ if (isFileTooLarge(file, maxBytes)) {
115
+ onUploadError?.(file, errorMessage);
116
+ return {
117
+ file,
118
+ state: "error" as UploadState,
119
+ progress: 0,
120
+ error: errorMessage,
121
+ };
122
+ }
123
+ return {
124
+ file,
125
+ state: "loading_config" as UploadState,
126
+ progress: 0,
127
+ };
128
+ });
129
+ setFileItems(items);
130
+ cancelledFilesRef.current.clear();
131
+
132
+ const itemsToUpload = items.filter((item) => item.state !== "error");
133
+ if (itemsToUpload.length === 0) {
134
+ e.target.value = "";
135
+ return;
136
+ }
137
+
138
+ let config: Awaited<ReturnType<typeof getUploadConfig>> | null = null;
139
+ let publishLocationId: string | null = null;
140
+ const uploadedFiles: UploadedFile[] = [];
141
+
142
+ for (const item of itemsToUpload) {
143
+ const { file } = item;
144
+
145
+ if (cancelledFilesRef.current.has(file.name)) {
146
+ setFileItems((prev) => updateItem(prev, file.name, { state: "cancelled", progress: 0 }));
147
+ continue;
148
+ }
149
+
150
+ try {
151
+ if (!config) {
152
+ config = await getUploadConfig();
153
+ }
154
+ setFileItems((prev) => updateItem(prev, file.name, { state: "loading_config" }));
155
+
156
+ if (cancelledFilesRef.current.has(file.name)) {
157
+ setFileItems((prev) =>
158
+ updateItem(prev, file.name, { state: "cancelled", progress: 0 }),
159
+ );
160
+ continue;
161
+ }
162
+
163
+ const abortController = new AbortController();
164
+ currentAbortControllerRef.current = abortController;
165
+ currentFileNameRef.current = file.name;
166
+
167
+ setFileItems((prev) => updateItem(prev, file.name, { state: "uploading", progress: 0 }));
168
+ const contentBodyId = await uploadToUrl(
169
+ file,
170
+ config.token,
171
+ config.uploadUrl,
172
+ (percent) => {
173
+ setFileItems((prev) => updateItem(prev, file.name, { progress: percent }));
174
+ },
175
+ abortController.signal,
176
+ );
177
+
178
+ currentAbortControllerRef.current = null;
179
+ currentFileNameRef.current = null;
180
+
181
+ if (cancelledFilesRef.current.has(file.name)) {
182
+ continue;
183
+ }
184
+
185
+ // Only create ContentVersion if recordId is provided
186
+ let contentVersionId: string | undefined;
187
+ if (recordId) {
188
+ if (!publishLocationId) {
189
+ publishLocationId = recordId;
190
+ }
191
+ setFileItems((prev) =>
192
+ updateItem(prev, file.name, { state: "creating_record", progress: 100 }),
193
+ );
194
+ contentVersionId = await createContentVersion(file, contentBodyId, publishLocationId);
195
+ }
196
+
197
+ flushSync(() => {
198
+ setFileItems((prev) =>
199
+ updateItem(prev, file.name, {
200
+ state: "success",
201
+ progress: 100,
202
+ contentBodyId,
203
+ contentVersionId,
204
+ }),
205
+ );
206
+ });
207
+ uploadedFiles.push({
208
+ name: file.name,
209
+ size: file.size,
210
+ contentBodyId,
211
+ contentVersionId,
212
+ });
213
+ } catch (err) {
214
+ currentAbortControllerRef.current = null;
215
+ currentFileNameRef.current = null;
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ const isCancelled =
218
+ message === "Upload aborted" || cancelledFilesRef.current.has(file.name);
219
+ setFileItems((prev) =>
220
+ updateItem(prev, file.name, {
221
+ state: isCancelled ? "cancelled" : "error",
222
+ error: isCancelled ? undefined : message,
223
+ }),
224
+ );
225
+ if (!isCancelled) {
226
+ onUploadError?.(file, message);
227
+ }
228
+ // Continue with remaining files; do not return early
229
+ }
230
+ }
231
+
232
+ if (uploadedFiles.length > 0) {
233
+ onUploadComplete?.(uploadedFiles);
234
+ }
235
+
236
+ e.target.value = "";
237
+ },
238
+ [recordId, onUploadComplete, onUploadError, maxBytes],
239
+ );
240
+
241
+ const handleDragOver = React.useCallback((e: React.DragEvent) => {
242
+ e.preventDefault();
243
+ e.stopPropagation();
244
+ setIsDragging(true);
245
+ }, []);
246
+
247
+ const handleDragLeave = React.useCallback((e: React.DragEvent) => {
248
+ e.preventDefault();
249
+ e.stopPropagation();
250
+ setIsDragging(false);
251
+ }, []);
252
+
253
+ const handleDrop = React.useCallback(
254
+ (e: React.DragEvent) => {
255
+ e.preventDefault();
256
+ e.stopPropagation();
257
+ setIsDragging(false);
258
+ const files = e.dataTransfer.files ? Array.from(e.dataTransfer.files) : [];
259
+ if (files.length > 0 && inputRef.current) {
260
+ const dt = new DataTransfer();
261
+ files.forEach((f) => dt.items.add(f));
262
+ inputRef.current.files = dt.files;
263
+ handleChange({ target: inputRef.current } as React.ChangeEvent<HTMLInputElement>);
264
+ }
265
+ },
266
+ [handleChange],
267
+ );
268
+
269
+ const handleClick = React.useCallback(() => {
270
+ inputRef.current?.click();
271
+ }, []);
272
+
273
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
274
+ if (e.key === "Enter" || e.key === " ") {
275
+ e.preventDefault();
276
+ inputRef.current?.click();
277
+ }
278
+ }, []);
279
+
280
+ const reset = React.useCallback(() => {
281
+ setFileItems([]);
282
+ }, []);
283
+
284
+ const allDone =
285
+ fileItems.length > 0 &&
286
+ fileItems.every(
287
+ (item) => item.state === "success" || item.state === "error" || item.state === "cancelled",
288
+ );
289
+
290
+ return {
291
+ fileItems,
292
+ allDone,
293
+ isDragging,
294
+ reset,
295
+ cancelFile,
296
+ openFilePicker: handleClick,
297
+ getInputProps: () => ({
298
+ ref: inputRef,
299
+ type: "file" as const,
300
+ accept,
301
+ multiple,
302
+ onChange: handleChange,
303
+ }),
304
+ getDropZoneProps: () => ({
305
+ onClick: handleClick,
306
+ onDragOver: handleDragOver,
307
+ onDragLeave: handleDragLeave,
308
+ onDrop: handleDrop,
309
+ onKeyDown: handleKeyDown,
310
+ }),
311
+ };
312
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hook: useFileUploadDialog
3
+ *
4
+ * Manages file upload dialog state: open/close and list of successfully uploaded file names.
5
+ * Powers the FileUpload component internally. Opens dialog when fileItems has items; on close,
6
+ * captures success names for display and resets upload state.
7
+ *
8
+ * Not exported from the package. Use the FileUpload component for file upload UIs.
9
+ *
10
+ * @param options - Dialog options (fileItems, reset)
11
+ * @returns Object containing dialogOpen, uploadedFileNames, handleOpenChange
12
+ *
13
+ * @remarks
14
+ * Coordinates with useFileUpload: opens when fileItems.length > 0, on close calls reset and
15
+ * accumulates successfully uploaded file names for the summary list below the drop zone.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const { dialogOpen, uploadedFileNames, handleOpenChange } = useFileUploadDialog({
20
+ * fileItems,
21
+ * reset,
22
+ * });
23
+ * ```
24
+ */
25
+ import * as React from "react";
26
+ import type { FileUploadItem } from "../types/fileUpload";
27
+
28
+ interface UseFileUploadDialogOptions {
29
+ fileItems: FileUploadItem[];
30
+ reset: () => void;
31
+ }
32
+
33
+ interface UseFileUploadDialogReturn {
34
+ dialogOpen: boolean;
35
+ uploadedFileNames: string[];
36
+ handleOpenChange: (open: boolean) => void;
37
+ }
38
+
39
+ export function useFileUploadDialog({
40
+ fileItems,
41
+ reset,
42
+ }: UseFileUploadDialogOptions): UseFileUploadDialogReturn {
43
+ const [dialogOpen, setDialogOpen] = React.useState(false);
44
+ const [uploadedFileNames, setUploadedFileNames] = React.useState<string[]>([]);
45
+
46
+ React.useEffect(() => {
47
+ if (fileItems.length > 0) {
48
+ setDialogOpen(true);
49
+ setUploadedFileNames([]);
50
+ }
51
+ }, [fileItems.length]);
52
+
53
+ const handleOpenChange = React.useCallback(
54
+ (open: boolean) => {
55
+ if (!open) {
56
+ const successNames = fileItems
57
+ .filter((item) => item.state === "success")
58
+ .map((item) => item.file.name);
59
+ if (successNames.length > 0) {
60
+ setUploadedFileNames((prev) => [...prev, ...successNames]);
61
+ }
62
+ reset();
63
+ }
64
+ setDialogOpen(open);
65
+ },
66
+ [fileItems, reset],
67
+ );
68
+
69
+ return { dialogOpen, uploadedFileNames, handleOpenChange };
70
+ }