diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index 6d6507969844..94011ebf8518 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -63,6 +63,10 @@ export const HelpMap = { path: "/widget-reference/filepicker", searchKey: "File picker", }, + FILE_PICKER_WIDGET_V2: { + path: "/widget-reference/filepicker", + searchKey: "File picker", + }, FORM_BUTTON_WIDGET: { path: "", searchKey: "", diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index afea29610dbc..a0a814af7604 100644 --- a/app/client/src/constants/WidgetConstants.tsx +++ b/app/client/src/constants/WidgetConstants.tsx @@ -23,6 +23,7 @@ export enum WidgetTypes { CANVAS_WIDGET = "CANVAS_WIDGET", ICON_WIDGET = "ICON_WIDGET", FILE_PICKER_WIDGET = "FILE_PICKER_WIDGET", + FILE_PICKER_WIDGET_V2 = "FILE_PICKER_WIDGET_V2", VIDEO_WIDGET = "VIDEO_WIDGET", SKELETON_WIDGET = "SKELETON_WIDGET", LIST_WIDGET = "LIST_WIDGET", diff --git a/app/client/src/icons/WidgetIcons.tsx b/app/client/src/icons/WidgetIcons.tsx index 39283d99ff2b..3db9aa22ca26 100644 --- a/app/client/src/icons/WidgetIcons.tsx +++ b/app/client/src/icons/WidgetIcons.tsx @@ -115,7 +115,7 @@ export const WidgetIcons: { ), - FILE_PICKER_WIDGET: (props: IconProps) => ( + FILE_PICKER_WIDGET_V2: (props: IconProps) => ( diff --git a/app/client/src/mockResponses/WidgetConfigResponse.tsx b/app/client/src/mockResponses/WidgetConfigResponse.tsx index b110f849033b..536379e79b51 100644 --- a/app/client/src/mockResponses/WidgetConfigResponse.tsx +++ b/app/client/src/mockResponses/WidgetConfigResponse.tsx @@ -409,6 +409,23 @@ const WidgetConfigResponse: WidgetConfigReducerState = { isRequired: false, isDisabled: false, }, + FILE_PICKER_WIDGET_V2: { + rows: 1 * GRID_DENSITY_MIGRATION_V1, + files: [], + selectedFiles: [], + defaultSelectedFiles: [], + allowedFileTypes: [], + label: "Select Files", + columns: 4 * GRID_DENSITY_MIGRATION_V1, + maxNumFiles: 1, + maxFileSize: 5, + fileDataType: FileDataTypes.Base64, + widgetName: "FilePicker", + isDefaultClickDisabled: true, + version: 1, + isRequired: false, + isDisabled: false, + }, TABS_WIDGET: { rows: 7 * GRID_DENSITY_MIGRATION_V1, columns: 8 * GRID_DENSITY_MIGRATION_V1, diff --git a/app/client/src/mockResponses/WidgetSidebarResponse.tsx b/app/client/src/mockResponses/WidgetSidebarResponse.tsx index 8ab4b6a73333..adf7a1eca3bf 100644 --- a/app/client/src/mockResponses/WidgetSidebarResponse.tsx +++ b/app/client/src/mockResponses/WidgetSidebarResponse.tsx @@ -45,7 +45,7 @@ const WidgetSidebarResponse: WidgetCardProps[] = [ key: generateReactKey(), }, { - type: "FILE_PICKER_WIDGET", + type: "FILE_PICKER_WIDGET_V2", widgetCardName: "FilePicker", key: generateReactKey(), }, diff --git a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx index dbe9bff5d8b3..5359f2d6655d 100644 --- a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx +++ b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx @@ -15,6 +15,8 @@ import { DropdownWidgetProps } from "widgets/DropdownWidget"; import { CheckboxWidgetProps } from "widgets/CheckboxWidget"; import { RadioGroupWidgetProps } from "widgets/RadioGroupWidget"; import { FilePickerWidgetProps } from "widgets/FilepickerWidget"; +import { FilePickerWidgetV2Props } from "widgets/FilepickerWidgetV2"; + import { TabsWidgetProps, TabContainerWidgetProps, @@ -76,6 +78,7 @@ export interface WidgetConfigReducerState { SWITCH_WIDGET: Partial & WidgetConfigProps; RADIO_GROUP_WIDGET: Partial & WidgetConfigProps; FILE_PICKER_WIDGET: Partial & WidgetConfigProps; + FILE_PICKER_WIDGET_V2: Partial & WidgetConfigProps; TABS_WIDGET: Partial> & WidgetConfigProps; TABS_MIGRATOR_WIDGET: Partial> & diff --git a/app/client/src/sagas/ActionExecutionSagas.ts b/app/client/src/sagas/ActionExecutionSagas.ts index 850bdc117479..0d46e1ec404c 100644 --- a/app/client/src/sagas/ActionExecutionSagas.ts +++ b/app/client/src/sagas/ActionExecutionSagas.ts @@ -72,7 +72,7 @@ import { isActionSaving, } from "selectors/entitiesSelector"; import { AppState } from "reducers"; -import { mapToPropList } from "utils/AppsmithUtils"; +import { isBlobUrl, mapToPropList, parseBlobUrl } from "utils/AppsmithUtils"; import { validateResponse } from "sagas/ErrorSagas"; import { TypeOptions } from "react-toastify"; import { DEFAULT_EXECUTE_ACTION_TIMEOUT_MS } from "constants/ApiConstants"; @@ -119,6 +119,7 @@ import { ENTITY_TYPE, PLATFORM_ERROR } from "entities/AppsmithConsole"; import LOG_TYPE from "entities/AppsmithConsole/logtype"; import { matchPath } from "react-router"; import { setDataUrl } from "./PageSagas"; +import FileDataTypes from "widgets/FileDataTypes"; enum ActionResponseDataTypes { BINARY = "BINARY", @@ -481,11 +482,16 @@ export function* evaluateActionParams( // Convert to object and transform non string values const actionParams: Record = {}; - bindings.forEach((key, i) => { + for (let i = 0; i < bindings.length; i++) { + const key = bindings[i]; let value = values[i]; if (typeof value === "object") value = JSON.stringify(value); + if (isBlobUrl(value)) { + value = yield call(readBlob, value); + } + actionParams[key] = value; - }); + } return mapToPropList(actionParams); } @@ -1139,3 +1145,28 @@ export function* watchActionExecutionSagas() { ), ]); } + +/** + * + * @param blobUrl string A blob url with type added a query param + * @returns promise that resolves to file content + */ +function* readBlob(blobUrl: string): any { + const [url, fileType] = parseBlobUrl(blobUrl); + const file = yield fetch(url).then((r) => r.blob()); + + const data = yield new Promise((resolve) => { + const reader = new FileReader(); + if (fileType === FileDataTypes.Base64) { + reader.readAsDataURL(file); + } else if (fileType === FileDataTypes.Binary) { + reader.readAsBinaryString(file); + } else { + reader.readAsText(file); + } + reader.onloadend = () => { + resolve(reader.result); + }; + }); + return data; +} diff --git a/app/client/src/utils/AppsmithUtils.tsx b/app/client/src/utils/AppsmithUtils.tsx index 6e9090a6b7c9..410e17084f3c 100644 --- a/app/client/src/utils/AppsmithUtils.tsx +++ b/app/client/src/utils/AppsmithUtils.tsx @@ -302,3 +302,35 @@ export const retryPromise = ( export const getRandomPaletteColor = (colorPalette: string[]) => { return colorPalette[Math.floor(Math.random() * colorPalette.length)]; }; + +export const isBlobUrl = (url: string) => { + return typeof url === "string" && url.startsWith("blob:"); +}; + +/** + * + * @param data string file data + * @param type string file type + * @returns string containing blob id and type + */ +export const createBlobUrl = (data: string, type: string) => { + let url = URL.createObjectURL(data); + url = url.replace( + `${window.location.protocol}//${window.location.hostname}/`, + "", + ); + + return `${url}?type=${type}`; +}; + +/** + * + * @param blobId string blob id along with type. + * @returns [string,string] [blobUrl, type] + */ +export const parseBlobUrl = (blobId: string) => { + const url = `blob:${window.location.protocol}//${ + window.location.hostname + }/${blobId.substring(5)}`; + return url.split("?type="); +}; diff --git a/app/client/src/utils/TypeHelpers.ts b/app/client/src/utils/TypeHelpers.ts index 9b78ad223fbb..7b9a468f8f58 100644 --- a/app/client/src/utils/TypeHelpers.ts +++ b/app/client/src/utils/TypeHelpers.ts @@ -27,7 +27,7 @@ export const getType = (value: unknown) => { export function isURL(str: string) { const pattern = new RegExp( - "^(https?:\\/\\/)?" + // protocol + "^((blob:)?https?:\\/\\/)?" + // protocol "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx index cea93540f1d3..4d7c58ba5c27 100644 --- a/app/client/src/utils/WidgetRegistry.tsx +++ b/app/client/src/utils/WidgetRegistry.tsx @@ -67,6 +67,11 @@ import FilePickerWidget, { FilePickerWidgetProps, ProfiledFilePickerWidget, } from "widgets/FilepickerWidget"; + +import FilePickerWidgetV2, { + FilePickerWidgetV2Props, + ProfiledFilePickerWidgetV2, +} from "widgets/FilepickerWidgetV2"; import DatePickerWidget, { DatePickerWidgetProps, ProfiledDatePickerWidget, @@ -290,6 +295,20 @@ export default class WidgetBuilderRegistry { FilePickerWidget.getMetaPropertiesMap(), FilePickerWidget.getPropertyPaneConfig(), ); + + WidgetFactory.registerWidgetBuilder( + "FILE_PICKER_WIDGET_V2", + { + buildWidget(widgetData: FilePickerWidgetV2Props): JSX.Element { + return ; + }, + }, + FilePickerWidgetV2.getDerivedPropertiesMap(), + FilePickerWidgetV2.getDefaultPropertiesMap(), + FilePickerWidgetV2.getMetaPropertiesMap(), + FilePickerWidgetV2.getPropertyPaneConfig(), + ); + WidgetFactory.registerWidgetBuilder( "DATE_PICKER_WIDGET", { diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index e9ada0bae549..8664528976d5 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -248,6 +248,14 @@ export const entityDefinitions = { files: "[file]", isDisabled: "bool", }, + FILE_PICKER_WIDGET_V2: { + "!doc": + "Filepicker widget is used to allow users to upload files from their local machines to any cloud storage via API. Cloudinary and Amazon S3 have simple APIs for cloud storage uploads", + "!url": "https://docs.appsmith.com/widget-reference/filepicker", + isVisible: isVisible, + files: "[file]", + isDisabled: "bool", + }, LIST_WIDGET: (widget: any) => ({ "!doc": "Containers are used to group widgets together to form logical higher order widgets. Containers let you organize your page better and move all the widgets inside them together.", diff --git a/app/client/src/widgets/FilepickerWidgetV2.tsx b/app/client/src/widgets/FilepickerWidgetV2.tsx new file mode 100644 index 000000000000..26e15245b376 --- /dev/null +++ b/app/client/src/widgets/FilepickerWidgetV2.tsx @@ -0,0 +1,501 @@ +import React from "react"; +import BaseWidget, { WidgetProps, WidgetState } from "./BaseWidget"; +import { WidgetType } from "constants/WidgetConstants"; +import FilePickerComponent from "components/designSystems/appsmith/FilePickerComponent"; +import Uppy from "@uppy/core"; +import GoogleDrive from "@uppy/google-drive"; +import Webcam from "@uppy/webcam"; +import Url from "@uppy/url"; +import OneDrive from "@uppy/onedrive"; +import { ValidationTypes } from "constants/WidgetValidation"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; +import { DerivedPropertiesMap } from "utils/WidgetFactory"; +import Dashboard from "@uppy/dashboard"; +import shallowequal from "shallowequal"; +import _, { findIndex } from "lodash"; +import * as Sentry from "@sentry/react"; +import withMeta, { WithMeta } from "./MetaHOC"; +import FileDataTypes from "./FileDataTypes"; +import { EvaluationSubstitutionType } from "entities/DataTree/dataTreeFactory"; +import { createBlobUrl, isBlobUrl } from "utils/AppsmithUtils"; + +class FilePickerWidget extends BaseWidget< + FilePickerWidgetProps, + FilePickerWidgetState +> { + constructor(props: FilePickerWidgetProps) { + super(props); + this.state = { + isLoading: false, + uppy: this.initializeUppy(), + }; + } + + static getPropertyPaneConfig() { + return [ + { + sectionName: "General", + children: [ + { + propertyName: "label", + label: "Label", + controlType: "INPUT_TEXT", + helpText: "Sets the label of the button", + placeholderText: "Enter label text", + inputType: "TEXT", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "maxNumFiles", + label: "Max No. files", + helpText: + "Sets the maximum number of files that can be uploaded at once", + controlType: "INPUT_TEXT", + placeholderText: "Enter no. of files", + inputType: "INTEGER", + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.NUMBER }, + }, + { + propertyName: "maxFileSize", + helpText: "Sets the maximum size of each file that can be uploaded", + label: "Max file size(Mb)", + controlType: "INPUT_TEXT", + placeholderText: "File size in mb", + inputType: "INTEGER", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.NUMBER, + params: { min: 1, max: 100, default: 5, required: true }, + }, + }, + { + propertyName: "allowedFileTypes", + helpText: "Restricts the type of files which can be uploaded", + label: "Allowed File Types", + controlType: "MULTI_SELECT", + placeholderText: "Select file types", + options: [ + { + label: "Any File", + value: "*", + }, + { + label: "Images", + value: "image/*", + }, + { + label: "Videos", + value: "video/*", + }, + { + label: "Audio", + value: "audio/*", + }, + { + label: "Text", + value: "text/*", + }, + { + label: "MS Word", + value: ".doc", + }, + { + label: "JPEG", + value: "image/jpeg", + }, + { + label: "PNG", + value: ".png", + }, + ], + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.ARRAY, + params: { + allowedValues: [ + "*", + "image/*", + "video/*", + "audio/*", + "text/*", + ".doc", + "image/jpeg", + ".png", + ], + }, + }, + evaluationSubstitutionType: + EvaluationSubstitutionType.SMART_SUBSTITUTE, + }, + { + helpText: "Set the format of the data read from the files", + propertyName: "fileDataType", + label: "Data Format", + controlType: "DROP_DOWN", + options: [ + { + label: FileDataTypes.Base64, + value: FileDataTypes.Base64, + }, + { + label: FileDataTypes.Binary, + value: FileDataTypes.Binary, + }, + { + label: FileDataTypes.Text, + value: FileDataTypes.Text, + }, + ], + isBindProperty: false, + isTriggerProperty: false, + }, + { + propertyName: "isRequired", + label: "Required", + helpText: "Makes input to the widget mandatory", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "isVisible", + label: "Visible", + helpText: "Controls the visibility of the widget", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + propertyName: "isDisabled", + label: "Disable", + helpText: "Disables input to this widget", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, + { + sectionName: "Actions", + children: [ + { + helpText: + "Triggers an action when the user selects a file. Upload files to a CDN and stores their URLs in filepicker.files", + propertyName: "onFilesSelected", + label: "onFilesSelected", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + ], + }, + ]; + } + + static getDefaultPropertiesMap(): Record { + return { + selectedFiles: "defaultSelectedFiles", + }; + } + + static getDerivedPropertiesMap(): DerivedPropertiesMap { + return { + isValid: `{{ this.isRequired ? this.files.length > 0 : true }}`, + files: `{{this.selectedFiles}}`, + }; + } + + static getMetaPropertiesMap(): Record { + return { + selectedFiles: [], + uploadedFileData: {}, + }; + } + + /** + * if uppy is not initialized before, initialize it + * else setState of uppy instance + */ + initializeUppy = () => { + const uppyState = { + id: this.props.widgetId, + autoProceed: false, + allowMultipleUploads: true, + debug: false, + restrictions: { + maxFileSize: this.props.maxFileSize + ? this.props.maxFileSize * 1024 * 1024 + : null, + maxNumberOfFiles: this.props.maxNumFiles, + minNumberOfFiles: null, + allowedFileTypes: + this.props.allowedFileTypes && + (this.props.allowedFileTypes.includes("*") || + _.isEmpty(this.props.allowedFileTypes)) + ? null + : this.props.allowedFileTypes, + }, + }; + + return Uppy(uppyState); + }; + + /** + * set states on the uppy instance with new values + */ + reinitializeUppy = (props: FilePickerWidgetProps) => { + const uppyState = { + id: props.widgetId, + autoProceed: false, + allowMultipleUploads: true, + debug: false, + restrictions: { + maxFileSize: props.maxFileSize ? props.maxFileSize * 1024 * 1024 : null, + maxNumberOfFiles: props.maxNumFiles, + minNumberOfFiles: null, + allowedFileTypes: + props.allowedFileTypes && + (this.props.allowedFileTypes.includes("*") || + _.isEmpty(props.allowedFileTypes)) + ? null + : props.allowedFileTypes, + }, + }; + + this.state.uppy.setOptions(uppyState); + }; + + /** + * add all uppy events listeners needed + */ + initializeUppyEventListeners = () => { + this.state.uppy + .use(Dashboard, { + target: "body", + metaFields: [], + inline: false, + width: 750, + height: 550, + thumbnailWidth: 280, + showLinkToFileUploadResult: true, + showProgressDetails: false, + hideUploadButton: false, + hideProgressAfterFinish: false, + note: null, + closeAfterFinish: true, + closeModalOnClickOutside: true, + disableStatusBar: false, + disableInformer: false, + disableThumbnailGenerator: false, + disablePageScrollWhenModalOpen: true, + proudlyDisplayPoweredByUppy: false, + onRequestCloseModal: () => { + const plugin = this.state.uppy.getPlugin("Dashboard"); + + if (plugin) { + plugin.closeModal(); + } + }, + locale: { + strings: { + closeModal: "Close", + }, + }, + }) + .use(GoogleDrive, { companionUrl: "https://companion.uppy.io" }) + .use(Url, { companionUrl: "https://companion.uppy.io" }) + .use(OneDrive, { + companionUrl: "https://companion.uppy.io/", + }) + .use(Webcam, { + onBeforeSnapshot: () => Promise.resolve(), + countdown: false, + mirror: true, + facingMode: "user", + locale: {}, + }); + + this.state.uppy.on("file-removed", (file: any) => { + const updatedFiles = this.props.selectedFiles + ? this.props.selectedFiles.filter((dslFile) => { + return file.id !== dslFile.id; + }) + : []; + this.props.updateWidgetMetaProperty("selectedFiles", updatedFiles); + }); + + this.state.uppy.on("files-added", (files: any[]) => { + const dslFiles = this.props.selectedFiles + ? [...this.props.selectedFiles] + : []; + + const fileCount = this.props.selectedFiles?.length || 0; + const fileReaderPromises = files.map((file, index) => { + return new Promise((resolve) => { + if (file.size < 5000 * 1000) { + const reader = new FileReader(); + if (this.props.fileDataType === FileDataTypes.Base64) { + reader.readAsDataURL(file.data); + } else if (this.props.fileDataType === FileDataTypes.Binary) { + reader.readAsBinaryString(file.data); + } else { + reader.readAsText(file.data); + } + reader.onloadend = () => { + const newFile = { + type: file.type, + id: file.id, + data: reader.result, + name: file.meta ? file.meta.name : `File-${index + fileCount}`, + size: file.size, + }; + resolve(newFile); + }; + } else { + const data = createBlobUrl(file.data, this.props.fileDataType); + const newFile = { + type: file.type, + id: file.id, + data: data, + name: file.meta ? file.meta.name : `File-${index + fileCount}`, + size: file.size, + }; + resolve(newFile); + } + }); + }); + + Promise.all(fileReaderPromises).then((files) => { + this.props.updateWidgetMetaProperty( + "selectedFiles", + dslFiles.concat(files), + ); + }); + }); + + this.state.uppy.on("upload", () => { + this.onFilesSelected(); + }); + }; + + /** + * this function is called when user selects the files and it do two things: + * 1. calls the action if any + * 2. set isLoading prop to true when calling the action + */ + onFilesSelected = () => { + if (this.props.onFilesSelected) { + this.executeAction({ + triggerPropertyName: "onFilesSelected", + dynamicString: this.props.onFilesSelected, + event: { + type: EventType.ON_FILES_SELECTED, + callback: this.handleActionComplete, + }, + }); + + this.setState({ isLoading: true }); + } + }; + + handleActionComplete = () => { + this.setState({ isLoading: false }); + }; + + componentDidUpdate(prevProps: FilePickerWidgetProps) { + super.componentDidUpdate(prevProps); + if ( + prevProps.selectedFiles && + prevProps.selectedFiles.length > 0 && + this.props.selectedFiles === undefined + ) { + this.state.uppy.reset(); + } else if ( + !shallowequal(prevProps.allowedFileTypes, this.props.allowedFileTypes) || + prevProps.maxNumFiles !== this.props.maxNumFiles || + prevProps.maxFileSize !== this.props.maxFileSize + ) { + this.reinitializeUppy(this.props); + } + this.clearFilesFromMemory(prevProps.selectedFiles); + } + // Reclaim the memory used by blobs. + clearFilesFromMemory(previousFiles: any[] = []) { + const { selectedFiles: newFiles = [] } = this.props; + previousFiles.forEach((file: any) => { + let { data: blobUrl } = file; + if (isBlobUrl(blobUrl)) { + if (findIndex(newFiles, (f) => f.data === blobUrl) === -1) { + blobUrl = blobUrl.split("?")[0]; + URL.revokeObjectURL(blobUrl); + } + } + }); + } + + componentDidMount() { + super.componentDidMount(); + + this.initializeUppyEventListeners(); + } + + componentWillUnmount() { + this.state.uppy.close(); + } + + getPageView() { + return ( + + ); + } + + getWidgetType(): WidgetType { + return "FILE_PICKER_WIDGET"; + } +} + +interface FilePickerWidgetState extends WidgetState { + isLoading: boolean; + uppy: any; +} + +interface FilePickerWidgetProps extends WidgetProps, WithMeta { + label: string; + maxNumFiles?: number; + maxFileSize?: number; + selectedFiles?: any[]; + allowedFileTypes: string[]; + onFilesSelected?: string; + fileDataType: FileDataTypes; + isRequired?: boolean; +} + +export type FilePickerWidgetV2Props = FilePickerWidgetProps; +export type FilePickerWidgetV2State = FilePickerWidgetState; + +export default FilePickerWidget; +export const ProfiledFilePickerWidgetV2 = Sentry.withProfiler( + withMeta(FilePickerWidget), +);