@salesforce/webapp-template-feature-react-file-upload-experimental 1.60.1 → 1.60.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 (35) hide show
  1. package/README.md +97 -11
  2. package/dist/CHANGELOG.md +8 -0
  3. package/dist/force-app/main/default/webapplications/feature-react-file-upload/package-lock.json +15 -15
  4. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/api/fileUpload.ts +4 -12
  5. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUpload.tsx +20 -3
  6. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDialog.tsx +2 -2
  7. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDropZone.tsx +48 -40
  8. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadFileItem.tsx +1 -1
  9. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadIcons.tsx +2 -2
  10. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUpload.ts +36 -23
  11. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +18 -0
  12. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/pages/Home.tsx +10 -23
  13. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/pages/UploadTest.tsx +56 -0
  14. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +8 -3
  15. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/types/fileUpload.ts +2 -0
  16. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/utils/fileUploadUtils.ts +12 -2
  17. package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/utils/labels.ts +2 -0
  18. package/dist/package.json +1 -1
  19. package/package.json +3 -3
  20. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/api/fileUpload.ts +4 -12
  21. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUpload.tsx +20 -3
  22. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDialog.tsx +2 -2
  23. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDropZone.tsx +48 -40
  24. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadFileItem.tsx +1 -1
  25. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadIcons.tsx +2 -2
  26. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/__inherit__button.tsx +45 -0
  27. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/__inherit__dialog.tsx +102 -0
  28. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUpload.ts +36 -23
  29. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +18 -0
  30. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/pages/UploadTest.tsx +56 -0
  31. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +8 -3
  32. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/types/fileUpload.ts +2 -0
  33. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/utils/fileUploadUtils.ts +12 -2
  34. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/utils/labels.ts +2 -0
  35. package/src/force-app/main/default/webapplications/feature-react-file-upload/src/pages/Home.tsx +0 -25
package/README.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  File upload feature: use the **FileUpload** component as-is with out-of-the-box UI, or build a **custom UI** with the `useFileUpload` hook (custom trigger, drop zone, or both).
4
4
 
5
+ ## Exports
6
+
7
+ ### Components & Hooks
8
+
9
+ - **FileUpload** – Component with OOTB drop zone, dialog, and progress. Props: `accept?`, `multiple?`, `recordId?`, `onUploadComplete?`, `onUploadError?`, `className?`, `dropZoneClassName?`, `formatHint?`, `maxFileSize?`.
10
+ - **useFileUpload** – Headless hook for custom UIs. Returns `getInputProps`, `openFilePicker`, `fileItems`, `getDropZoneProps`, `cancelFile`, `reset`, etc.
11
+ - **UseFileUploadOptions** – Type for the `useFileUpload` options.
12
+
13
+ ### API Functions
14
+
15
+ - **createContentVersion** – Manually create a ContentVersion record from a contentBodyId.
16
+
17
+ ### FileUpload component Props
18
+
19
+ - **`recordId`** (optional): When provided, creates ContentVersion and links to this record. When omitted, only uploads file and returns `contentBodyId` without creating ContentVersion.
20
+ - **`maxFileSize`** (optional): Maximum file size in MB. Files exceeding this limit are rejected. Default: 2 GB.
21
+ - **`dropZoneClassName`** (optional): CSS classes for the drop zone (e.g., `"h-full"` for flex layouts).
22
+ - **`formatHint`** (optional): Text hint shown in drop zone (e.g., "JPEG, PNG, PDF, up to 50MB").
23
+ - **`onUploadComplete`**: Receives array of uploaded files with `name`, `size`, `contentBodyId`, and `contentVersionId` (if ContentVersion was created).
24
+
5
25
  ## Usage
6
26
 
7
27
  ### Option 1: FileUpload component (OOTB UI)
@@ -14,11 +34,62 @@ import { FileUpload } from "@salesforce/webapp-template-feature-react-file-uploa
14
34
  <FileUpload
15
35
  multiple
16
36
  accept="image/*,.pdf"
17
- onUploadComplete={(files) => console.log("Uploaded:", files)}
37
+ recordId={accountId} // Creates ContentVersion and links to this record
38
+ maxFileSize={50} // 50 MB limit
39
+ formatHint="JPEG, PNG, PDF, up to 50MB"
40
+ onUploadComplete={(files) => {
41
+ // files[0].contentBodyId: "069..."
42
+ // files[0].contentVersionId: "068..."
43
+ console.log("Uploaded:", files);
44
+ }}
18
45
  />;
19
46
  ```
20
47
 
21
- ### Option 2: Custom UI with your own trigger
48
+ ### Option 2: Upload file only (no ContentVersion)
49
+
50
+ Omit `recordId` to only upload the file and get the `contentBodyId`:
51
+
52
+ ```tsx
53
+ <FileUpload
54
+ multiple
55
+ onUploadComplete={(files) => {
56
+ // Only contentBodyId is returned, no ContentVersion created
57
+ console.log("Content Body ID:", files[0].contentBodyId);
58
+ console.log("Content Version ID:", files[0].contentVersionId); // undefined
59
+ }}
60
+ />
61
+ ```
62
+
63
+ ### Option 3: Manual ContentVersion creation
64
+
65
+ Upload file first, then manually create ContentVersion:
66
+
67
+ ```tsx
68
+ import {
69
+ FileUpload,
70
+ createContentVersion,
71
+ getCurrentUserId,
72
+ } from "@salesforce/webapp-template-feature-react-file-upload-experimental";
73
+
74
+ function MyComponent() {
75
+ const handleComplete = async (files) => {
76
+ const userId = await getCurrentUserId();
77
+
78
+ for (const file of files) {
79
+ const contentVersionId = await createContentVersion(
80
+ new File([""], file.name),
81
+ file.contentBodyId,
82
+ userId,
83
+ );
84
+ console.log("Created ContentVersion:", contentVersionId);
85
+ }
86
+ };
87
+
88
+ return <FileUpload onUploadComplete={handleComplete} />;
89
+ }
90
+ ```
91
+
92
+ ### Option 4: Custom UI with your own trigger
22
93
 
23
94
  Use the `useFileUpload` hook to control when the file picker opens. Render a hidden input and your own button or action.
24
95
 
@@ -31,6 +102,7 @@ import {
31
102
  function CustomUploadButton() {
32
103
  const { getInputProps, openFilePicker } = useFileUpload({
33
104
  multiple: true,
105
+ maxFileSize: 50, // 50 MB limit
34
106
  onUploadComplete: (files) => console.log("Uploaded:", files),
35
107
  });
36
108
 
@@ -45,7 +117,7 @@ function CustomUploadButton() {
45
117
  }
46
118
  ```
47
119
 
48
- ### Option 3: Custom UI with drop zone and progress
120
+ ### Option 5: Custom UI with drop zone and progress
49
121
 
50
122
  Build a custom drop zone and inline progress list (no dialog) using `getDropZoneProps` and `fileItems`:
51
123
 
@@ -55,6 +127,8 @@ import { useFileUpload } from "@salesforce/webapp-template-feature-react-file-up
55
127
  function MyUpload() {
56
128
  const { fileItems, getInputProps, getDropZoneProps, reset } = useFileUpload({
57
129
  multiple: true,
130
+ recordId: accountId,
131
+ maxFileSize: 100, // 100 MB limit
58
132
  onUploadComplete: (files) => console.log("Uploaded files:", files),
59
133
  });
60
134
 
@@ -77,17 +151,29 @@ function MyUpload() {
77
151
  }
78
152
  ```
79
153
 
80
- ## Exports
154
+ ## API Reference
81
155
 
82
- - **FileUpload** – Component with OOTB drop zone, dialog, and progress. Props: `accept?`, `multiple?`, `recordId?`, `onUploadComplete?`, `onUploadError?`, `className?`.
83
- - **useFileUpload** – Headless hook for custom UIs. Returns `getInputProps`, `openFilePicker`, `fileItems`, `getDropZoneProps`, `cancelFile`, `reset`, etc.
84
- - **UseFileUploadOptions** Type for the `useFileUpload` options.
156
+ ### createContentVersion(file, contentBodyId, recordId)
157
+
158
+ Manually create a ContentVersion record from a previously uploaded file.
159
+
160
+ **Parameters:**
161
+
162
+ - `file` (File): The file object (used for metadata like name)
163
+ - `contentBodyId` (string): The ContentBody ID from a previous upload
164
+ - `recordId` (string): The record ID for FirstPublishLocationId (ex: Salesforce record id to attach file)
165
+
166
+ **Returns:** `Promise<string | undefined>` - The ContentVersion ID if successful
167
+
168
+ ```tsx
169
+ const contentVersionId = await createContentVersion(file, "069xx000000xxxx", "005xx000000yyyy");
170
+ ```
85
171
 
86
172
  ## Dependencies
87
173
 
88
174
  This feature depends on:
89
175
 
90
- - **feature-react-shadcn** – For UI components (Button, Dialog, etc.)
176
+ - **@salesforce/webapp-template-feature-react-shadcn-experimental** – For UI components (Button, Dialog, etc.)
91
177
  - **@salesforce/webapp-experimental** – For API client and Salesforce integration
92
178
 
93
179
  ## Build & Testing
@@ -95,8 +181,8 @@ This feature depends on:
95
181
  You can test the extension of the base app and the components via the following:
96
182
 
97
183
  ```bash
98
- npm run build
99
- npm run start
184
+ npx nx build
185
+ npx nx start
100
186
  ```
101
187
 
102
- Open the app and use the Home page to test the FileUpload component.
188
+ Open the app and navigate to the **Upload Test** page to test the FileUpload component with manual ContentVersion creation.
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.60.2](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.60.1...v1.60.2) (2026-02-27)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
6
14
  ## [1.60.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.60.0...v1.60.1) (2026-02-27)
7
15
 
8
16
  **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
@@ -6071,19 +6071,19 @@
6071
6071
  }
6072
6072
  },
6073
6073
  "node_modules/@salesforce/sdk-core": {
6074
- "version": "1.60.0",
6075
- "resolved": "https://registry.npmjs.org/@salesforce/sdk-core/-/sdk-core-1.60.0.tgz",
6076
- "integrity": "sha512-W6efNzdRfv4NM7urU/lCjRJJ9DQSEHl1xTtts2l/XjlgNFG2MIHr9D/EGuMbtRKgqV0cU5SE/HekRNCdYxNaGQ==",
6074
+ "version": "1.60.1",
6075
+ "resolved": "https://registry.npmjs.org/@salesforce/sdk-core/-/sdk-core-1.60.1.tgz",
6076
+ "integrity": "sha512-o0S83oNDkiRgymXQvThBBk8VPTY8PbBYkAlCHyhebdMCirv5ADUIndJA/wtadSp2JhvkGidU2MDTucKOuf922w==",
6077
6077
  "license": "SEE LICENSE IN LICENSE.txt"
6078
6078
  },
6079
6079
  "node_modules/@salesforce/sdk-data": {
6080
- "version": "1.60.0",
6081
- "resolved": "https://registry.npmjs.org/@salesforce/sdk-data/-/sdk-data-1.60.0.tgz",
6082
- "integrity": "sha512-zlqudhAqHfe5pufEsaTHG+e0WirRR36Mjbd9iaMyeo6nnG3F8Gb6mselJY+5qQ9R+JtqLSiZLxgmHCp1aqv9Wg==",
6080
+ "version": "1.60.1",
6081
+ "resolved": "https://registry.npmjs.org/@salesforce/sdk-data/-/sdk-data-1.60.1.tgz",
6082
+ "integrity": "sha512-tNrmKi8xGUxLhM33PwhUzC/8/1fay699ERCkMHoMBYV211nowzFQGUHMFuLU8sjIfErm8QdCqzNMJ6spxno/dQ==",
6083
6083
  "license": "SEE LICENSE IN LICENSE.txt",
6084
6084
  "dependencies": {
6085
6085
  "@conduit-client/salesforce-lightning-service-worker": "^3.7.0",
6086
- "@salesforce/sdk-core": "^1.60.0"
6086
+ "@salesforce/sdk-core": "^1.60.1"
6087
6087
  }
6088
6088
  },
6089
6089
  "node_modules/@salesforce/ts-types": {
@@ -6096,15 +6096,15 @@
6096
6096
  }
6097
6097
  },
6098
6098
  "node_modules/@salesforce/vite-plugin-webapp-experimental": {
6099
- "version": "1.60.0",
6100
- "resolved": "https://registry.npmjs.org/@salesforce/vite-plugin-webapp-experimental/-/vite-plugin-webapp-experimental-1.60.0.tgz",
6101
- "integrity": "sha512-T3UkicN5pOnZxFtdPkJBVhmXvUJvWHMs849ISAykNH8LumgeajeRbRgx9/c4drMIGRSXVA8JQVSJjVnDTpK3Sg==",
6099
+ "version": "1.60.1",
6100
+ "resolved": "https://registry.npmjs.org/@salesforce/vite-plugin-webapp-experimental/-/vite-plugin-webapp-experimental-1.60.1.tgz",
6101
+ "integrity": "sha512-5hTBd6xmaTkgfmY1PGFuJqXvCuMTPiKeBqYNkKKl6me8uUWBUQ3s5blPCwvLZqz7quNRU+iqzZNz4Ccn7zZnhQ==",
6102
6102
  "dev": true,
6103
6103
  "license": "SEE LICENSE IN LICENSE.txt",
6104
6104
  "dependencies": {
6105
6105
  "@babel/core": "^7.28.4",
6106
6106
  "@babel/helper-plugin-utils": "^7.28.3",
6107
- "@salesforce/webapp-experimental": "^1.60.0"
6107
+ "@salesforce/webapp-experimental": "^1.60.1"
6108
6108
  },
6109
6109
  "engines": {
6110
6110
  "node": ">=20.0.0"
@@ -6114,13 +6114,13 @@
6114
6114
  }
6115
6115
  },
6116
6116
  "node_modules/@salesforce/webapp-experimental": {
6117
- "version": "1.60.0",
6118
- "resolved": "https://registry.npmjs.org/@salesforce/webapp-experimental/-/webapp-experimental-1.60.0.tgz",
6119
- "integrity": "sha512-XfcAv+l5qgiBEfMSw5UqLvy/cVjFvkBlkQduf8aHhsGufaMsRuSfSdvxdOlTDLGz5/ThBFA+1SmILeW4q2n/dw==",
6117
+ "version": "1.60.1",
6118
+ "resolved": "https://registry.npmjs.org/@salesforce/webapp-experimental/-/webapp-experimental-1.60.1.tgz",
6119
+ "integrity": "sha512-RLu1w4pJQKyLfaGM/kv16BVfmG4m2/ICzRthynthxoIC2O51p0404ihi0sJ2mmOb/bwKCgH3HI9w9IGlU6LuBA==",
6120
6120
  "license": "SEE LICENSE IN LICENSE.txt",
6121
6121
  "dependencies": {
6122
6122
  "@salesforce/core": "^8.23.4",
6123
- "@salesforce/sdk-data": "^1.60.0",
6123
+ "@salesforce/sdk-data": "^1.60.1",
6124
6124
  "axios": "^1.7.7",
6125
6125
  "micromatch": "^4.0.8",
6126
6126
  "path-to-regexp": "^8.3.0"
@@ -3,16 +3,8 @@
3
3
  */
4
4
 
5
5
  import { createRecord, getCurrentUser } from "@salesforce/webapp-experimental/api";
6
- import { createDataSDK, type DataSDK } from "@salesforce/sdk-data";
7
6
 
8
- // Lazy-initialized SDK instance
9
- let sdkInstance: DataSDK | null = null;
10
- async function getSDK() {
11
- if (!sdkInstance) {
12
- sdkInstance = await createDataSDK();
13
- }
14
- return sdkInstance;
15
- }
7
+ import { getDataSDK } from "@salesforce/sdk-data";
16
8
 
17
9
  declare const __SF_API_VERSION__: string;
18
10
 
@@ -25,7 +17,7 @@ export interface UploadConfig {
25
17
  * Get upload config (token, uploadUrl) from /connect/file/upload/config.
26
18
  */
27
19
  export async function getUploadConfig(): Promise<UploadConfig> {
28
- const sdk = await getSDK();
20
+ const sdk = await getDataSDK();
29
21
  if (!sdk?.fetch) {
30
22
  throw new Error("Failed to initialize SDK");
31
23
  }
@@ -120,10 +112,10 @@ function parseJsonResponse(text: string): Record<string, unknown> {
120
112
  export async function createContentVersion(
121
113
  file: File,
122
114
  contentBodyId: string,
123
- userId: string,
115
+ recordId: string,
124
116
  ): Promise<string | undefined> {
125
117
  const fields = {
126
- FirstPublishLocationId: userId,
118
+ FirstPublishLocationId: recordId,
127
119
  Title: fileNameWithoutExtension(file.name),
128
120
  PathOnClient: file.name,
129
121
  ContentBodyId: contentBodyId,
@@ -9,13 +9,21 @@ export interface FileUploadProps {
9
9
  accept?: string;
10
10
  /** Whether to allow multiple file selection. Default: false */
11
11
  multiple?: boolean;
12
- /** Record Id for FirstPublishLocationId (e.g. Account, Opportunity). When provided, files are linked to this record. Otherwise, current user Id is used. */
12
+ /** 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. */
13
13
  recordId?: string;
14
- /** Called when uploads complete. Receives array of successfully uploaded files with name, size, and contentVersionId. */
15
- onUploadComplete?: (files: { name: string; size: number; contentVersionId?: string }[]) => void;
14
+ /** Called when uploads complete. Receives array of successfully uploaded files with name, size, contentBodyId, and contentVersionId (if ContentVersion was created). */
15
+ onUploadComplete?: (
16
+ files: { name: string; size: number; contentBodyId: string; contentVersionId?: string }[],
17
+ ) => void;
16
18
  onUploadError?: (file: File, error: string) => void;
17
19
  /** Optional CSS class for the wrapper div */
18
20
  className?: string;
21
+ /** Optional CSS class for the drop zone (e.g. "h-full" for flex layouts) */
22
+ dropZoneClassName?: string;
23
+ /** Optional format hint for drop zone (e.g. "JPEG, PNG, PDF, and MP4 formats, up to 50MB") */
24
+ formatHint?: string;
25
+ /** Maximum file size in MB. Files exceeding this limit are rejected with an error. Omit for default (2 GB). */
26
+ maxFileSize?: number;
19
27
  }
20
28
 
21
29
  /**
@@ -30,6 +38,9 @@ export function FileUpload({
30
38
  onUploadComplete,
31
39
  onUploadError,
32
40
  className = "",
41
+ dropZoneClassName = "",
42
+ formatHint,
43
+ maxFileSize,
33
44
  }: FileUploadProps) {
34
45
  const { fileItems, getInputProps, getDropZoneProps, isDragging, reset, cancelFile, allDone } =
35
46
  useFileUpload({
@@ -38,6 +49,7 @@ export function FileUpload({
38
49
  recordId,
39
50
  onUploadComplete,
40
51
  onUploadError,
52
+ maxFileSize,
41
53
  });
42
54
 
43
55
  const { dialogOpen, uploadedFileNames, handleOpenChange } = useFileUploadDialog({
@@ -45,6 +57,9 @@ export function FileUpload({
45
57
  reset,
46
58
  });
47
59
 
60
+ const effectiveFormatHint =
61
+ formatHint ?? (maxFileSize != null ? `Up to ${maxFileSize}MB` : undefined);
62
+
48
63
  const inputProps = getInputProps();
49
64
  const dropZoneProps = getDropZoneProps();
50
65
 
@@ -55,6 +70,8 @@ export function FileUpload({
55
70
  inputProps={inputProps}
56
71
  dropZoneProps={dropZoneProps}
57
72
  isDragging={isDragging}
73
+ formatHint={effectiveFormatHint}
74
+ className={dropZoneClassName}
58
75
  />
59
76
 
60
77
  <FileUploadDialog
@@ -1,12 +1,12 @@
1
+ import { Button } from "./ui/button";
1
2
  import {
2
- Button,
3
3
  Dialog,
4
4
  DialogClose,
5
5
  DialogContent,
6
6
  DialogFooter,
7
7
  DialogHeader,
8
8
  DialogTitle,
9
- } from "@/components/ui";
9
+ } from "./ui/dialog";
10
10
  import { FileUploadFileItem } from "./FileUploadFileItem";
11
11
  import { formatUploadSummary } from "../utils/fileUploadUtils";
12
12
  import { LABELS } from "../utils/labels";
@@ -1,6 +1,7 @@
1
- import { UtilityIcon } from "./FileUploadIcons";
2
1
  import { LABELS } from "../utils/labels";
3
2
 
3
+ import { UtilityIcon } from "./FileUploadIcons";
4
+
4
5
  export interface FileUploadDropZoneProps {
5
6
  /** Props for the hidden file input (ref, type, accept, multiple, onChange) */
6
7
  inputProps: {
@@ -20,63 +21,70 @@ export interface FileUploadDropZoneProps {
20
21
  };
21
22
  /** Whether the user is currently dragging over the drop zone */
22
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;
23
28
  }
24
29
 
25
30
  const DROP_ZONE_BASE_CLASSES =
26
- "mb-2 flex cursor-pointer flex-wrap items-center gap-3 rounded-[4px] border border-dashed p-1 transition-colors";
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";
27
32
  const DROP_ZONE_DRAGGING_CLASSES = "border-blue-500 bg-blue-50";
28
- const DROP_ZONE_IDLE_CLASSES = "hover:bg-gray-50";
29
- const BORDER_COLOR_IDLE = "rgb(116, 116, 116)";
33
+ const DROP_ZONE_IDLE_CLASSES = "border-gray-300 hover:border-gray-400 hover:bg-gray-50";
30
34
 
31
35
  /**
32
- * Drop zone for file selection. Renders a dashed border area with "Upload Files"
33
- * button and "Or drop files" / "Drop files here" text. Accepts click and drag-and-drop.
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.
34
38
  * Uses a hidden file input; props come from useFileUpload.
35
39
  */
36
40
  export function FileUploadDropZone({
37
41
  inputProps,
38
42
  dropZoneProps,
39
43
  isDragging,
44
+ formatHint = LABELS.formatHint,
45
+ className = "",
40
46
  }: FileUploadDropZoneProps) {
41
47
  const dropZoneClassName = [
42
48
  DROP_ZONE_BASE_CLASSES,
43
49
  isDragging ? DROP_ZONE_DRAGGING_CLASSES : DROP_ZONE_IDLE_CLASSES,
44
- ].join(" ");
50
+ className,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" ");
45
54
 
46
55
  return (
47
- <>
48
- <p className="mb-1 text-sm font-semibold text-gray-900">{LABELS.attach}</p>
49
- <div
50
- role="button"
51
- tabIndex={0}
52
- onClick={dropZoneProps.onClick}
53
- onDragOver={dropZoneProps.onDragOver}
54
- onDragLeave={dropZoneProps.onDragLeave}
55
- onDrop={dropZoneProps.onDrop}
56
- onKeyDown={dropZoneProps.onKeyDown}
57
- className={dropZoneClassName}
58
- style={!isDragging ? { borderColor: BORDER_COLOR_IDLE } : undefined}
59
- aria-label={LABELS.dropZone}
60
- data-testid="file-upload-drop-zone"
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
61
81
  >
62
- <input
63
- id="file-upload-input-id"
64
- ref={inputProps.ref}
65
- type="file"
66
- accept={inputProps.accept}
67
- multiple={inputProps.multiple}
68
- onChange={inputProps.onChange}
69
- className="sr-only"
70
- aria-hidden
71
- />
72
- <span className="inline-flex items-center gap-2 rounded-[4px] border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-blue-600 hover:bg-gray-50">
73
- <UtilityIcon id="upload" size="sm" />
74
- {LABELS.uploadFiles}
75
- </span>
76
- <span className="text-sm text-gray-900">
77
- {isDragging ? LABELS.dropFilesHere : LABELS.orDropFiles}
78
- </span>
79
- </div>
80
- </>
82
+ <UtilityIcon id="image" />
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>
81
89
  );
82
90
  }
@@ -1,4 +1,4 @@
1
- import { Button } from "@/components/ui";
1
+ import { Button } from "./ui/button";
2
2
  import { FileTypeIcon, UtilityIcon } from "./FileUploadIcons";
3
3
  import {
4
4
  formatFileSize,
@@ -1,6 +1,6 @@
1
- import symbolsUrl from "@assets/symbols.svg?url";
1
+ import symbolsUrl from "../assets/symbols.svg?url";
2
2
 
3
- import utilitySvg from "@assets/utility.svg?url";
3
+ import utilitySvg from "../assets/utility.svg?url";
4
4
 
5
5
  const IMAGE_EXTENSIONS = new Set([
6
6
  "jpg",
@@ -13,14 +13,14 @@
13
13
  */
14
14
  import * as React from "react";
15
15
  import { flushSync } from "react-dom";
16
- import {
17
- getUploadConfig,
18
- uploadToUrl,
19
- createContentVersion,
20
- getCurrentUserId,
21
- } from "../api/fileUpload";
16
+ import { getUploadConfig, uploadToUrl, createContentVersion } from "../api/fileUpload";
22
17
  import type { FileUploadItem, UploadedFile, UploadState } from "../types/fileUpload";
23
- import { isFileTooLarge, MAX_FILE_SIZE_BYTES, formatFileSize } from "../utils/fileUploadUtils";
18
+ import {
19
+ isFileTooLarge,
20
+ MAX_FILE_SIZE_BYTES,
21
+ bytesFromMB,
22
+ formatFileSize,
23
+ } from "../utils/fileUploadUtils";
24
24
  import { LABELS } from "../utils/labels";
25
25
 
26
26
  export interface UseFileUploadOptions {
@@ -28,12 +28,14 @@ export interface UseFileUploadOptions {
28
28
  accept?: string;
29
29
  /** Whether to allow multiple file selection. Default: false */
30
30
  multiple?: boolean;
31
- /** Record Id for FirstPublishLocationId (e.g. Account, Opportunity). When provided, files are linked to this record. Otherwise, current user Id is used. */
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
32
  recordId?: string;
33
- /** Called when uploads complete. Receives array of successfully uploaded files with name, size, and contentVersionId. */
33
+ /** Called when uploads complete. Receives array of successfully uploaded files with name, size, contentBodyId, and contentVersionId (if ContentVersion was created). */
34
34
  onUploadComplete?: (files: UploadedFile[]) => void;
35
35
  /** Called when an upload fails. Receives the file and error message. */
36
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;
37
39
  }
38
40
 
39
41
  function updateItem(
@@ -69,7 +71,16 @@ interface UseFileUploadReturn {
69
71
  }
70
72
 
71
73
  export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {
72
- const { accept, multiple = false, recordId, onUploadComplete, onUploadError } = options;
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;
73
84
 
74
85
  const [fileItems, setFileItems] = React.useState<FileUploadItem[]>([]);
75
86
  const [isDragging, setIsDragging] = React.useState(false);
@@ -96,11 +107,11 @@ export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUpload
96
107
  const files = e.target.files ? Array.from(e.target.files) : [];
97
108
  if (files.length === 0) return;
98
109
 
99
- const maxSizeLabel = formatFileSize(MAX_FILE_SIZE_BYTES);
110
+ const maxSizeLabel = formatFileSize(maxBytes);
100
111
  const errorMessage = LABELS.fileTooLarge(maxSizeLabel);
101
112
 
102
113
  const items: FileUploadItem[] = files.map((file) => {
103
- if (isFileTooLarge(file)) {
114
+ if (isFileTooLarge(file, maxBytes)) {
104
115
  onUploadError?.(file, errorMessage);
105
116
  return {
106
117
  file,
@@ -171,23 +182,24 @@ export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUpload
171
182
  continue;
172
183
  }
173
184
 
174
- if (!publishLocationId) {
175
- publishLocationId = recordId ?? (await getCurrentUserId());
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);
176
195
  }
177
- setFileItems((prev) =>
178
- updateItem(prev, file.name, { state: "creating_record", progress: 100 }),
179
- );
180
- const contentVersionId = await createContentVersion(
181
- file,
182
- contentBodyId,
183
- publishLocationId,
184
- );
185
196
 
186
197
  flushSync(() => {
187
198
  setFileItems((prev) =>
188
199
  updateItem(prev, file.name, {
189
200
  state: "success",
190
201
  progress: 100,
202
+ contentBodyId,
191
203
  contentVersionId,
192
204
  }),
193
205
  );
@@ -195,6 +207,7 @@ export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUpload
195
207
  uploadedFiles.push({
196
208
  name: file.name,
197
209
  size: file.size,
210
+ contentBodyId,
198
211
  contentVersionId,
199
212
  });
200
213
  } catch (err) {
@@ -222,7 +235,7 @@ export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUpload
222
235
 
223
236
  e.target.value = "";
224
237
  },
225
- [recordId, onUploadComplete, onUploadError],
238
+ [recordId, onUploadComplete, onUploadError, maxBytes],
226
239
  );
227
240
 
228
241
  const handleDragOver = React.useCallback((e: React.DragEvent) => {
@@ -54,3 +54,21 @@ export { useFileUpload } from "./hooks/useFileUpload";
54
54
  * @see useFileUpload
55
55
  */
56
56
  export type { UseFileUploadOptions } from "./hooks/useFileUpload";
57
+
58
+ /**
59
+ * Create ContentVersion record programmatically. Useful when you need to create
60
+ * ContentVersion separately from the file upload process (e.g., after uploading
61
+ * file to a different location first).
62
+ *
63
+ * @param file - The file to create ContentVersion for
64
+ * @param contentBodyId - The ContentBody ID from a previous upload
65
+ * @param recordId - The record ID for FirstPublishLocationId (e.g., Account, Opportunity, or User ID)
66
+ * @returns ContentVersion ID if successful
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * const contentBodyId = await uploadToUrl(file, token, uploadUrl, onProgress);
71
+ * const contentVersionId = await createContentVersion(file, contentBodyId, recordId);
72
+ * ```
73
+ */
74
+ export { createContentVersion, getCurrentUserId } from "./api/fileUpload";