From b82c41eedd964e162785cd96f6ced3d04f25381d Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Fri, 26 Nov 2021 08:28:50 -0800 Subject: [PATCH] Add FileSelector shared component Summary: Add shared FileSelector component compatibe with the new FlipperLib API Reviewed By: mweststrate Differential Revision: D32667100 fbshipit-source-id: dca1e8b7693d134a99617e916c7cfd30432cef78 --- desktop/flipper-plugin/package.json | 1 + .../flipper-plugin/src/__tests__/api.node.tsx | 1 + desktop/flipper-plugin/src/index.ts | 1 + .../flipper-plugin/src/plugin/FlipperLib.tsx | 7 - .../flipper-plugin/src/ui/FileSelector.tsx | 237 ++++++++++++++++++ .../flipper-plugin/src/utils/assertNever.tsx | 14 ++ desktop/yarn.lock | 6 +- docs/extending/flipper-plugin.mdx | 15 ++ 8 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/FileSelector.tsx create mode 100644 desktop/flipper-plugin/src/utils/assertNever.tsx diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index cec09b79e..b600d982f 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -16,6 +16,7 @@ "@types/uuid": "^8.3.1", "flipper-common": "0.0.0", "immer": "^9.0.6", + "js-base64": "^3.7.2", "lodash": "^4.17.21", "react-color": "^2.19.3", "react-element-to-jsx-string": "^14.3.4", diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 30f61eb57..1a974dca4 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -38,6 +38,7 @@ test('Correct top level API exposed', () => { "DetailSidebar", "Dialog", "ElementsInspector", + "FileSelector", "Layout", "MarkerTimeline", "MasterDetail", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 583e3d170..b95b78ec3 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -96,6 +96,7 @@ export {Panel} from './ui/Panel'; export {Tabs, Tab} from './ui/Tabs'; export {useLocalStorageState} from './utils/useLocalStorageState'; +export {FileSelector} from './ui/FileSelector'; export {HighlightManager} from './ui/Highlight'; export { DataValueExtractor, diff --git a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx index 01686583c..a8cb3045c 100644 --- a/desktop/flipper-plugin/src/plugin/FlipperLib.tsx +++ b/desktop/flipper-plugin/src/plugin/FlipperLib.tsx @@ -88,13 +88,6 @@ export interface FlipperLib { encoding?: FileEncoding; multi: true; }): Promise; - importFile(options?: { - defaultPath?: string; - extensions?: string[]; - title?: string; - encoding?: FileEncoding; - multi?: boolean; - }): Promise; /** * @returns diff --git a/desktop/flipper-plugin/src/ui/FileSelector.tsx b/desktop/flipper-plugin/src/ui/FileSelector.tsx new file mode 100644 index 000000000..e1f8ba084 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/FileSelector.tsx @@ -0,0 +1,237 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import React, { + CSSProperties, + DragEventHandler, + KeyboardEventHandler, + useState, +} from 'react'; +import {Button, Input, Row, Col, Tooltip} from 'antd'; +import { + CloseOutlined, + ExclamationCircleOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import { + FileDescriptor, + FileEncoding, + FlipperLib, + getFlipperLib, +} from '../plugin/FlipperLib'; +import {fromUint8Array} from 'js-base64'; +import {assertNever} from '../utils/assertNever'; + +export type FileSelectorProps = { + /** + * Placeholder text displayed in the Input when it is empty + */ + label: string; + /** + * List of allowed file extentions + */ + extensions?: string[]; + required?: boolean; + className?: string; + style?: CSSProperties; + /** + * Imported file encoding. Default: UTF-8. + */ + encoding?: FileEncoding; +} & ( + | { + multi?: false; + onChange: (newFile?: FileDescriptor) => void; + } + | { + multi: true; + onChange: (newFiles: FileDescriptor[]) => void; + } +); + +const formatFileDescriptor = (fileDescriptor?: FileDescriptor) => + fileDescriptor?.path || fileDescriptor?.name; + +export function FileSelector({ + onChange, + label, + extensions, + required, + className, + style, + encoding = 'utf-8', + multi, +}: FileSelectorProps) { + const [loading, setLoading] = useState(false); + const [files, setFiles] = useState([]); + + const onSetFiles = async () => { + setLoading(true); + + let defaultPath: string | undefined = files[0]?.path ?? files[0]?.name; + if (multi) { + defaultPath = files[0]?.path; + } + + try { + const newFileSelection = await getFlipperLib().importFile?.({ + defaultPath, + extensions, + title: label, + encoding, + multi, + } as Parameters[0]); + + if (!newFileSelection) { + return; + } + + if (Array.isArray(newFileSelection)) { + if (!newFileSelection.length) { + return; + } + + setFiles(newFileSelection); + (onChange as (newFiles: FileDescriptor[]) => void)(newFileSelection); + } else { + setFiles([newFileSelection]); + (onChange as (newFiles?: FileDescriptor) => void)(newFileSelection); + } + } catch (e) { + console.error('FileSelector.onSetFile -> error', label, e); + } finally { + setLoading(false); + } + }; + + const onFilesDrop: DragEventHandler = async (e) => { + setLoading(true); + + try { + if (!e.dataTransfer.files.length) { + return; + } + + const droppedFiles = multi + ? Array.from(e.dataTransfer.files) + : [e.dataTransfer.files[0]]; + + const droppedFileSelection = await Promise.all( + droppedFiles.map(async (droppedFile) => { + const raw = await droppedFile.arrayBuffer(); + + let data: string; + switch (encoding) { + case 'utf-8': { + data = new TextDecoder().decode(raw); + break; + } + case 'base64': { + data = fromUint8Array(new Uint8Array(raw)); + break; + } + default: { + assertNever(encoding); + } + } + + const droppedFileDescriptor: FileDescriptor = { + data: data!, + name: droppedFile.name, + // Electron "File" has "path" attribute + path: (droppedFile as any).path, + }; + return droppedFileDescriptor; + }), + ); + + setFiles(droppedFileSelection); + if (multi) { + (onChange as (newFiles: FileDescriptor[]) => void)( + droppedFileSelection, + ); + } else { + (onChange as (newFiles?: FileDescriptor) => void)( + droppedFileSelection[0], + ); + } + } catch (e) { + console.error('FileSelector.onFileDrop -> error', label, e); + } finally { + setLoading(false); + } + }; + + const captureEnterPress: KeyboardEventHandler = (e) => { + if (e.key === 'Enter') { + onSetFiles(); + } + }; + + const emptyFileListEventHandlers = !files.length + ? {onClick: onSetFiles, onKeyUp: captureEnterPress} + : {}; + + const inputProps = { + placeholder: label, + disabled: loading, + onDrop: onFilesDrop, + ...emptyFileListEventHandlers, + }; + + return ( + + + {multi ? ( + + ) : ( + + )} + + {required && !files.length ? ( + + + + + + ) : null} + +