diff --git a/x-pack/examples/files_example/common/index.ts b/x-pack/examples/files_example/common/index.ts index 1586d92c4c05a..aeb807e30aadf 100644 --- a/x-pack/examples/files_example/common/index.ts +++ b/x-pack/examples/files_example/common/index.ts @@ -8,14 +8,14 @@ import type { FileKind, FileImageMetadata } from '@kbn/files-plugin/common'; export const PLUGIN_ID = 'filesExample'; -export const PLUGIN_NAME = 'filesExample'; +export const PLUGIN_NAME = 'Files example'; const httpTags = { tags: [`access:${PLUGIN_ID}`], }; export const exampleFileKind: FileKind = { - id: 'filesExample', + id: PLUGIN_ID, allowedMimeTypes: ['image/png'], http: { create: httpTags, diff --git a/x-pack/examples/files_example/kibana.json b/x-pack/examples/files_example/kibana.json index 5df1141929c41..b9cc4027a43f4 100644 --- a/x-pack/examples/files_example/kibana.json +++ b/x-pack/examples/files_example/kibana.json @@ -9,6 +9,6 @@ "description": "Example plugin integrating with files plugin", "server": true, "ui": true, - "requiredPlugins": ["files"], + "requiredPlugins": ["files", "developerExamples"], "optionalPlugins": [] } diff --git a/x-pack/examples/files_example/public/application.tsx b/x-pack/examples/files_example/public/application.tsx index 3bdbae462f6b3..0bad6975c6da0 100644 --- a/x-pack/examples/files_example/public/application.tsx +++ b/x-pack/examples/files_example/public/application.tsx @@ -22,7 +22,7 @@ export const renderApp = ( ) => { ReactDOM.render( - + , diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index cf0f4461b8b62..d3dfbdeb71874 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { CoreStart } from '@kbn/core/public'; +import { MyFilePicker } from './file_picker'; import type { MyImageMetadata } from '../../common'; import type { FileClients } from '../types'; import { DetailsFlyout } from './details_flyout'; @@ -39,11 +40,19 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = files.example.list() ); const [showUploadModal, setShowUploadModal] = useState(false); + const [showFilePickerModal, setShowFilePickerModal] = useState(false); const [isDeletingFile, setIsDeletingFile] = useState(false); const [selectedItem, setSelectedItem] = useState>(); const renderToolsRight = () => { return [ + setShowFilePickerModal(true)} + isDisabled={isLoading || isDeletingFile} + iconType="eye" + > + Select a file + , setShowUploadModal(true)} isDisabled={isLoading || isDeletingFile} @@ -155,6 +164,18 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = }} /> )} + {showFilePickerModal && ( + setShowFilePickerModal(false)} + onDone={(ids) => { + notifications.toasts.addSuccess({ + title: 'Selected files!', + text: 'IDS:' + JSON.stringify(ids, null, 2), + }); + setShowFilePickerModal(false); + }} + /> + )} ); }; diff --git a/x-pack/examples/files_example/public/components/file_picker.tsx b/x-pack/examples/files_example/public/components/file_picker.tsx new file mode 100644 index 0000000000000..2bf5530655ba3 --- /dev/null +++ b/x-pack/examples/files_example/public/components/file_picker.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; + +import { exampleFileKind } from '../../common'; + +import { FilePicker } from '../imports'; + +interface Props { + onClose: () => void; + onDone: (ids: string[]) => void; +} + +export const MyFilePicker: FunctionComponent = ({ onClose, onDone }) => { + return ; +}; diff --git a/x-pack/examples/files_example/public/components/modal.tsx b/x-pack/examples/files_example/public/components/modal.tsx index 9d323b240f416..d8289257617cf 100644 --- a/x-pack/examples/files_example/public/components/modal.tsx +++ b/x-pack/examples/files_example/public/components/modal.tsx @@ -27,8 +27,8 @@ export const Modal: FunctionComponent = ({ onDismiss, onUploaded, client diff --git a/x-pack/examples/files_example/public/imports.ts b/x-pack/examples/files_example/public/imports.ts index 7758883d0da83..a60d9cb4a6a36 100644 --- a/x-pack/examples/files_example/public/imports.ts +++ b/x-pack/examples/files_example/public/imports.ts @@ -12,5 +12,8 @@ export { UploadFile, FilesContext, ScopedFilesClient, + FilePicker, Image, } from '@kbn/files-plugin/public'; + +export type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; diff --git a/x-pack/examples/files_example/public/plugin.ts b/x-pack/examples/files_example/public/plugin.ts index 98a6b6f6e4608..4906b59d4d6fc 100644 --- a/x-pack/examples/files_example/public/plugin.ts +++ b/x-pack/examples/files_example/public/plugin.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { AppNavLinkStatus } from '@kbn/core-application-browser'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { PLUGIN_ID, PLUGIN_NAME, exampleFileKind, MyImageMetadata } from '../common'; import { FilesExamplePluginsStart, FilesExamplePluginsSetup } from './types'; @@ -12,12 +13,22 @@ import { FilesExamplePluginsStart, FilesExamplePluginsSetup } from './types'; export class FilesExamplePlugin implements Plugin { - public setup(core: CoreSetup, { files }: FilesExamplePluginsSetup) { + public setup( + core: CoreSetup, + { files, developerExamples }: FilesExamplePluginsSetup + ) { files.registerFileKind(exampleFileKind); + developerExamples.register({ + appId: PLUGIN_ID, + title: PLUGIN_NAME, + description: 'Example plugin for the files plugin', + }); + core.application.register({ id: PLUGIN_ID, title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { // Load application bundle const { renderApp } = await import('./application'); diff --git a/x-pack/examples/files_example/public/types.ts b/x-pack/examples/files_example/public/types.ts index fbc058d9aec30..0ac384055aaf3 100644 --- a/x-pack/examples/files_example/public/types.ts +++ b/x-pack/examples/files_example/public/types.ts @@ -6,10 +6,17 @@ */ import { MyImageMetadata } from '../common'; -import type { FilesSetup, FilesStart, ScopedFilesClient, FilesClient } from './imports'; +import type { + FilesSetup, + FilesStart, + ScopedFilesClient, + FilesClient, + DeveloperExamplesSetup, +} from './imports'; export interface FilesExamplePluginsSetup { files: FilesSetup; + developerExamples: DeveloperExamplesSetup; } export interface FilesExamplePluginsStart { diff --git a/x-pack/examples/files_example/tsconfig.json b/x-pack/examples/files_example/tsconfig.json index caeb25650a142..e75078a80019c 100644 --- a/x-pack/examples/files_example/tsconfig.json +++ b/x-pack/examples/files_example/tsconfig.json @@ -15,11 +15,8 @@ ], "exclude": [], "references": [ - { - "path": "../../../src/core/tsconfig.json" - }, - { - "path": "../../plugins/files/tsconfig.json" - } + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../plugins/files/tsconfig.json" }, + { "path": "../../../examples/developer_examples/tsconfig.json" } ] } diff --git a/x-pack/plugins/files/public/components/context.tsx b/x-pack/plugins/files/public/components/context.tsx index e55c0c45e4da6..a18ea212beffe 100644 --- a/x-pack/plugins/files/public/components/context.tsx +++ b/x-pack/plugins/files/public/components/context.tsx @@ -7,9 +7,14 @@ import React, { createContext, useContext, type FunctionComponent } from 'react'; import { FileKindsRegistry, getFileKindsRegistry } from '../../common/file_kinds_registry'; +import type { FilesClient } from '../types'; export interface FilesContextValue { registry: FileKindsRegistry; + /** + * A files client that will be used process uploads. + */ + client: FilesClient; } const FilesContextObject = createContext(null as unknown as FilesContextValue); @@ -21,10 +26,18 @@ export const useFilesContext = () => { } return ctx; }; -export const FilesContext: FunctionComponent = ({ children }) => { + +interface ContextProps { + /** + * A files client that will be used process uploads. + */ + client: FilesClient; +} +export const FilesContext: FunctionComponent = ({ client, children }) => { return ( diff --git a/x-pack/plugins/files/public/components/file_picker/components/clear_filter_button.tsx b/x-pack/plugins/files/public/components/file_picker/components/clear_filter_button.tsx new file mode 100644 index 0000000000000..14356b9b02bd4 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/clear_filter_button.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import type { FunctionComponent } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiLink } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFilePickerContext } from '../context'; + +import { i18nTexts } from '../i18n_texts'; + +interface Props { + onClick: () => void; +} + +export const ClearFilterButton: FunctionComponent = ({ onClick }) => { + const { state } = useFilePickerContext(); + const query = useObservable(state.queryDebounced$); + if (!query) { + return null; + } + return ( +
+ {i18nTexts.clearFilterButton} +
+ ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/error_content.tsx b/x-pack/plugins/files/public/components/file_picker/components/error_content.tsx new file mode 100644 index 0000000000000..c2925c793fe63 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/error_content.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18nTexts } from '../i18n_texts'; +import { useFilePickerContext } from '../context'; +import { useBehaviorSubject } from '../../use_behavior_subject'; + +interface Props { + error: Error; +} + +export const ErrorContent: FunctionComponent = ({ error }) => { + const { state } = useFilePickerContext(); + const isLoading = useBehaviorSubject(state.isLoading$); + return ( + {i18nTexts.loadingFilesErrorTitle}} + body={error.message} + actions={ + + {i18nTexts.retryButtonLabel} + + } + /> + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/file_card.scss b/x-pack/plugins/files/public/components/file_picker/components/file_card.scss new file mode 100644 index 0000000000000..f2a10651f6dea --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/file_card.scss @@ -0,0 +1,5 @@ +.filesFilePicker { + .euiCard__content, .euiCard__description { + margin :0; // make the cards a little bit more compact + } +} \ No newline at end of file diff --git a/x-pack/plugins/files/public/components/file_picker/components/file_card.tsx b/x-pack/plugins/files/public/components/file_picker/components/file_card.tsx new file mode 100644 index 0000000000000..4c290b1b114e7 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/file_card.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; +import numeral from '@elastic/numeral'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiCard, EuiText, EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FileImageMetadata, FileJSON } from '../../../../common'; +import { Image } from '../../image'; +import { isImage } from '../../util'; +import { useFilePickerContext } from '../context'; + +import './file_card.scss'; + +interface Props { + file: FileJSON; +} + +export const FileCard: FunctionComponent = ({ file }) => { + const { kind, state, client } = useFilePickerContext(); + const { euiTheme } = useEuiTheme(); + const displayImage = isImage({ type: file.mimeType }); + + const isSelected = useObservable(state.watchFileSelected$(file.id), false); + + const imageHeight = `calc(${euiTheme.size.xxxl} * 2)`; + return ( + (isSelected ? state.unselectFile(file.id) : state.selectFile(file.id)), + }} + image={ +
+ {displayImage ? ( + {file.alt + ) : ( +
+ +
+ )} +
+ } + description={ + <> + + {file.name} + + + {numeral(file.size).format('0[.]0 b')} + {file.extension && ( + <> +   ·   + + {file.extension} + + + )} + + + } + hasBorder + /> + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/file_grid.tsx b/x-pack/plugins/files/public/components/file_picker/components/file_grid.tsx new file mode 100644 index 0000000000000..2f2a9722d55b7 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/file_grid.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; +import { useEuiTheme, EuiEmptyPrompt } from '@elastic/eui'; +import { css } from '@emotion/react'; +import useObservable from 'react-use/lib/useObservable'; + +import { i18nTexts } from '../i18n_texts'; +import { useFilePickerContext } from '../context'; +import { FileCard } from './file_card'; + +export const FileGrid: FunctionComponent = () => { + const { state } = useFilePickerContext(); + const { euiTheme } = useEuiTheme(); + const files = useObservable(state.files$, []); + if (!files.length) { + return {i18nTexts.emptyFileGridPrompt}} titleSize="s" />; + } + return ( +
+ {files.map((file, idx) => ( + + ))} +
+ ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/modal_footer.tsx b/x-pack/plugins/files/public/components/file_picker/components/modal_footer.tsx new file mode 100644 index 0000000000000..d0d0e146d2c3b --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/modal_footer.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiModalFooter } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { Pagination } from './pagination'; +import { SelectButton, Props as SelectButtonProps } from './select_button'; + +interface Props { + onDone: SelectButtonProps['onClick']; +} + +export const ModalFooter: FunctionComponent = ({ onDone }) => { + return ( + + + + + + + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/pagination.tsx b/x-pack/plugins/files/public/components/file_picker/components/pagination.tsx new file mode 100644 index 0000000000000..bc2d0d444ba45 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/pagination.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; +import { EuiPagination } from '@elastic/eui'; +import { useFilePickerContext } from '../context'; +import { useBehaviorSubject } from '../../use_behavior_subject'; + +export const Pagination: FunctionComponent = () => { + const { state } = useFilePickerContext(); + const page = useBehaviorSubject(state.currentPage$); + const pageCount = useBehaviorSubject(state.totalPages$); + return ; +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/search_field.tsx b/x-pack/plugins/files/public/components/file_picker/components/search_field.tsx new file mode 100644 index 0000000000000..0235b03dd3fc1 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/search_field.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { EuiFieldSearch } from '@elastic/eui'; +import { i18nTexts } from '../i18n_texts'; +import { useFilePickerContext } from '../context'; +import { useBehaviorSubject } from '../../use_behavior_subject'; + +export const SearchField: FunctionComponent = () => { + const { state } = useFilePickerContext(); + const query = useBehaviorSubject(state.query$); + const isLoading = useBehaviorSubject(state.isLoading$); + const hasFiles = useBehaviorSubject(state.hasFiles$); + return ( + state.setQuery(ev.target.value)} + /> + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/select_button.tsx b/x-pack/plugins/files/public/components/file_picker/components/select_button.tsx new file mode 100644 index 0000000000000..ac5e241c01d53 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/select_button.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { useBehaviorSubject } from '../../use_behavior_subject'; +import { useFilePickerContext } from '../context'; +import { i18nTexts } from '../i18n_texts'; + +export interface Props { + onClick: (selectedFiles: string[]) => void; +} + +export const SelectButton: FunctionComponent = ({ onClick }) => { + const { state } = useFilePickerContext(); + const selectedFiles = useBehaviorSubject(state.selectedFileIds$); + return ( + onClick(selectedFiles)} + > + {selectedFiles.length > 1 + ? i18nTexts.selectFilesLabel(selectedFiles.length) + : i18nTexts.selectFileLabel} + + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/title.tsx b/x-pack/plugins/files/public/components/file_picker/components/title.tsx new file mode 100644 index 0000000000000..de1015241f656 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/title.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import type { FunctionComponent } from 'react'; +import { EuiTitle } from '@elastic/eui'; +import { i18nTexts } from '../i18n_texts'; + +export const Title: FunctionComponent = () => ( + +

{i18nTexts.title}

+
+); diff --git a/x-pack/plugins/files/public/components/file_picker/components/upload_files.tsx b/x-pack/plugins/files/public/components/file_picker/components/upload_files.tsx new file mode 100644 index 0000000000000..143d20fd63ec0 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/upload_files.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import { UploadFile } from '../../upload_file'; +import { useFilePickerContext } from '../context'; +import { i18nTexts } from '../i18n_texts'; + +interface Props { + kind: string; +} + +export const UploadFilesPrompt: FunctionComponent = ({ kind }) => { + const { state } = useFilePickerContext(); + return ( + {i18nTexts.emptyStatePrompt}} + body={ + +

{i18nTexts.emptyStatePromptSubtitle}

+
+ } + titleSize="s" + actions={[ + // TODO: We can remove this once the entire modal is an upload area + { + state.selectFile(file.map(({ id }) => id)); + state.retry(); + }} + />, + ]} + /> + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx new file mode 100644 index 0000000000000..67e745b745829 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useMemo, useEffect } from 'react'; +import type { FunctionComponent } from 'react'; +import { useFilesContext, FilesContextValue } from '../context'; +import { FilePickerState, createFilePickerState } from './file_picker_state'; + +interface FilePickerContextValue extends FilesContextValue { + state: FilePickerState; + kind: string; +} + +const FilePickerCtx = createContext( + null as unknown as FilePickerContextValue +); + +interface FilePickerContextProps { + kind: string; + pageSize: number; +} +export const FilePickerContext: FunctionComponent = ({ + kind, + pageSize, + children, +}) => { + const filesContext = useFilesContext(); + const { client } = filesContext; + const state = useMemo( + () => createFilePickerState({ pageSize, client, kind }), + [pageSize, client, kind] + ); + useEffect(() => state.dispose, [state]); + return ( + + {children} + + ); +}; + +export const useFilePickerContext = (): FilePickerContextValue => { + const ctx = useContext(FilePickerCtx); + if (!ctx) throw new Error('FilePickerContext not found!'); + return ctx; +}; diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker.scss b/x-pack/plugins/files/public/components/file_picker/file_picker.scss new file mode 100644 index 0000000000000..a7ec792564500 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.scss @@ -0,0 +1,8 @@ +.filesFilePicker--fixed { + @include euiBreakpoint('m', 'l', 'xl', 'xxl') { + width: 75vw; + .euiModal__flex { + height: 75vw; + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker.stories.tsx b/x-pack/plugins/files/public/components/file_picker/file_picker.stories.tsx new file mode 100644 index 0000000000000..9d40b112b4060 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.stories.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import type { FileJSON } from '../../../common'; +import { FilesClient, FilesClientResponses } from '../../types'; +import { register } from '../stories_shared'; +import { base64dLogo } from '../image/image.constants.stories'; +import { FilesContext } from '../context'; +import { FilePicker, Props as FilePickerProps } from './file_picker'; + +const kind = 'filepicker'; +register({ + id: kind, + http: {}, + allowedMimeTypes: ['*'], +}); + +const defaultProps: FilePickerProps = { + kind, + onDone: action('done!'), + onClose: action('close!'), +}; + +export default { + title: 'components/FilePicker', + component: FilePicker, + args: defaultProps, + decorators: [ + (Story) => ( + Promise.reject(new Error('not so fast buster!')), + list: async (): Promise => ({ + files: [], + total: 0, + }), + } as unknown as FilesClient + } + > + + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (props) => ; + +export const Empty = Template.bind({}); + +const d = new Date(); +let id = 0; +function createFileJSON(file?: Partial): FileJSON { + return { + alt: '', + created: d.toISOString(), + updated: d.toISOString(), + extension: 'png', + fileKind: kind, + id: String(++id), + meta: { + width: 1000, + height: 1000, + }, + mimeType: 'image/png', + name: 'my file', + size: 1, + status: 'READY', + ...file, + }; +} +export const BasicOne = Template.bind({}); +BasicOne.decorators = [ + (Story) => ( + `data:image/png;base64,${base64dLogo}`, + list: async (): Promise => ({ + files: [createFileJSON()], + total: 1, + }), + } as unknown as FilesClient + } + > + + + ), +]; + +export const BasicMany = Template.bind({}); +BasicMany.decorators = [ + (Story) => { + const files = [ + createFileJSON({ name: 'abc' }), + createFileJSON({ name: 'def' }), + createFileJSON({ name: 'efg' }), + createFileJSON({ name: 'foo' }), + createFileJSON({ name: 'bar' }), + createFileJSON(), + createFileJSON(), + ]; + + return ( + `data:image/png;base64,${base64dLogo}`, + list: async (): Promise => ({ + files, + total: files.length, + }), + } as unknown as FilesClient + } + > + + + ); + }, +]; + +export const BasicManyMany = Template.bind({}); +BasicManyMany.decorators = [ + (Story) => { + const array = new Array(102); + array.fill(null); + return ( + `data:image/png;base64,${base64dLogo}`, + list: async (): Promise => ({ + files: array.map((_, idx) => createFileJSON({ id: String(idx) })), + total: array.length, + }), + } as unknown as FilesClient + } + > + + + ); + }, +]; + +export const ErrorLoading = Template.bind({}); +ErrorLoading.decorators = [ + (Story) => { + const array = new Array(102); + array.fill(createFileJSON()); + return ( + `data:image/png;base64,${base64dLogo}`, + list: async () => { + throw new Error('stop'); + }, + } as unknown as FilesClient + } + > + + + ); + }, +]; + +export const TryFilter = Template.bind({}); +TryFilter.decorators = [ + (Story) => { + const array = { files: [createFileJSON()], total: 1 }; + return ( + <> +

Try entering a filter!

+ `data:image/png;base64,${base64dLogo}`, + list: async ({ name }: { name: string[] }) => { + if (name) { + return { files: [], total: 0 }; + } + return array; + }, + } as unknown as FilesClient + } + > + + + + ); + }, +]; diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker.test.tsx b/x-pack/plugins/files/public/components/file_picker/file_picker.test.tsx new file mode 100644 index 0000000000000..14b621050a0ef --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.test.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { registerTestBed } from '@kbn/test-jest-helpers'; + +import { createMockFilesClient } from '../../mocks'; +import { FilesContext } from '../context'; +import { FilePicker, Props } from './file_picker'; +import { + FileKindsRegistryImpl, + getFileKindsRegistry, + setFileKindsRegistry, +} from '../../../common/file_kinds_registry'; +import { FileJSON } from '../../../common'; + +describe('FilePicker', () => { + const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); + let client: ReturnType; + let onDone: jest.Mock; + let onClose: jest.Mock; + + async function initTestBed(props?: Partial) { + const createTestBed = registerTestBed((p: Props) => ( + + + + )); + + const testBed = await createTestBed({ + client, + kind: 'test', + onClose, + onDone, + ...props, + } as Props); + + const baseTestSubj = `filePickerModal`; + + const testSubjects = { + base: baseTestSubj, + searchField: `${baseTestSubj}.searchField`, + emptyPrompt: `${baseTestSubj}.emptyPrompt`, + errorPrompt: `${baseTestSubj}.errorPrompt`, + selectButton: `${baseTestSubj}.selectButton`, + loadingSpinner: `${baseTestSubj}.loadingSpinner`, + fileGrid: `${baseTestSubj}.fileGrid`, + }; + + return { + ...testBed, + actions: { + select: (n: number) => + act(() => { + const file = testBed.find(testSubjects.fileGrid).childAt(n).find(EuiButtonEmpty); + file.simulate('click'); + testBed.component.update(); + }), + done: () => + act(() => { + testBed.find(testSubjects.selectButton).simulate('click'); + }), + waitUntilLoaded: async () => { + let tries = 5; + while (tries) { + await act(async () => { + await sleep(100); + testBed.component.update(); + }); + if (!testBed.exists(testSubjects.loadingSpinner)) { + break; + } + --tries; + } + }, + }, + testSubjects, + }; + } + + beforeAll(() => { + setFileKindsRegistry(new FileKindsRegistryImpl()); + getFileKindsRegistry().register({ + id: 'test', + maxSizeBytes: 10000, + http: {}, + }); + }); + + beforeEach(() => { + jest.resetAllMocks(); + client = createMockFilesClient(); + onDone = jest.fn(); + onClose = jest.fn(); + }); + + it('intially shows a loadings spinner, then content', async () => { + client.list.mockImplementation(() => Promise.resolve({ files: [], total: 0 })); + const { exists, testSubjects, actions } = await initTestBed(); + expect(exists(testSubjects.loadingSpinner)).toBe(true); + await actions.waitUntilLoaded(); + expect(exists(testSubjects.loadingSpinner)).toBe(false); + }); + it('shows empty prompt when there are no files', async () => { + client.list.mockImplementation(() => Promise.resolve({ files: [], total: 0 })); + const { exists, testSubjects, actions } = await initTestBed(); + await actions.waitUntilLoaded(); + expect(exists(testSubjects.emptyPrompt)).toBe(true); + }); + it('returns the IDs of the selected files', async () => { + client.list.mockImplementation(() => + Promise.resolve({ files: [{ id: 'a' }, { id: 'b' }] as FileJSON[], total: 2 }) + ); + const { find, testSubjects, actions } = await initTestBed(); + await actions.waitUntilLoaded(); + expect(find(testSubjects.selectButton).props().disabled).toBe(true); + actions.select(0); + actions.select(1); + expect(find(testSubjects.selectButton).props().disabled).toBe(false); + actions.done(); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenNthCalledWith(1, ['a', 'b']); + }); +}); diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker.tsx b/x-pack/plugins/files/public/components/file_picker/file_picker.tsx new file mode 100644 index 0000000000000..72920b72a865d --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiLoadingSpinner, + EuiSpacer, + EuiFlexGroup, +} from '@elastic/eui'; + +import { useBehaviorSubject } from '../use_behavior_subject'; +import { useFilePickerContext, FilePickerContext } from './context'; + +import { Title } from './components/title'; +import { ErrorContent } from './components/error_content'; +import { UploadFilesPrompt } from './components/upload_files'; +import { FileGrid } from './components/file_grid'; +import { SearchField } from './components/search_field'; +import { ModalFooter } from './components/modal_footer'; + +import './file_picker.scss'; +import { ClearFilterButton } from './components/clear_filter_button'; + +export interface Props { + /** + * The file kind that was passed to the registry. + */ + kind: Kind; + /** + * Will be called when the modal is closed + */ + onClose: () => void; + /** + * Will be called after a user has a selected a set of files + */ + onDone: (fileIds: string[]) => void; + /** + * The number of results to show per page. + */ + pageSize?: number; +} + +const Component: FunctionComponent = ({ onClose, onDone }) => { + const { state, kind } = useFilePickerContext(); + + const hasFiles = useBehaviorSubject(state.hasFiles$); + const hasQuery = useBehaviorSubject(state.hasQuery$); + const isLoading = useBehaviorSubject(state.isLoading$); + const error = useBehaviorSubject(state.loadingError$); + + useObservable(state.files$); + + const renderFooter = () => ; + + return ( + + + + <SearchField /> + </EuiModalHeader> + {isLoading ? ( + <> + <EuiModalBody> + <EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="none"> + <EuiLoadingSpinner data-test-subj="loadingSpinner" size="xl" /> + </EuiFlexGroup> + </EuiModalBody> + {renderFooter()} + </> + ) : Boolean(error) ? ( + <EuiModalBody> + <ErrorContent error={error as Error} /> + </EuiModalBody> + ) : !hasFiles && !hasQuery ? ( + <EuiModalBody> + <UploadFilesPrompt kind={kind} /> + </EuiModalBody> + ) : ( + <> + <EuiModalBody> + <FileGrid /> + <EuiSpacer /> + <ClearFilterButton onClick={() => state.setQuery(undefined)} /> + </EuiModalBody> + {renderFooter()} + </> + )} + </EuiModal> + ); +}; + +export const FilePicker: FunctionComponent<Props> = (props) => ( + <FilePickerContext pageSize={props.pageSize ?? 20} kind={props.kind}> + <Component {...props} /> + </FilePickerContext> +); + +/* eslint-disable import/no-default-export */ +export default FilePicker; diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker_state.test.ts b/x-pack/plugins/files/public/components/file_picker/file_picker_state.test.ts new file mode 100644 index 0000000000000..79eb5cbfa529d --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker_state.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('rxjs', () => { + const rxjs = jest.requireActual('rxjs'); + return { + ...rxjs, + debounceTime: rxjs.tap, + }; +}); + +import { TestScheduler } from 'rxjs/testing'; +import { merge, tap, of, NEVER } from 'rxjs'; +import { FileJSON } from '../../../common'; +import { FilePickerState, createFilePickerState } from './file_picker_state'; +import { createMockFilesClient } from '../../mocks'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => expect(actual).toEqual(expected)); + +describe('FilePickerState', () => { + let filePickerState: FilePickerState; + let filesClient: ReturnType<typeof createMockFilesClient>; + beforeEach(() => { + filesClient = createMockFilesClient(); + filePickerState = createFilePickerState({ + client: filesClient, + pageSize: 20, + kind: 'test', + }); + }); + it('starts off empty', () => { + expect(filePickerState.hasFilesSelected()).toBe(false); + }); + it('updates when files are added', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold('--a-b|').pipe(tap((id) => filePickerState.selectFile(id))); + expectObservable(addFiles$).toBe('--a-b|'); + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-', { + a: [], + b: ['a'], + c: ['a', 'b'], + }); + flush(); + expect(filePickerState.hasFilesSelected()).toBe(true); + expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); + }); + }); + it('adds files simultaneously as one update', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold('--a|').pipe(tap(() => filePickerState.selectFile(['1', '2', '3']))); + expectObservable(addFiles$).toBe('--a|'); + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-', { + a: [], + b: ['1', '2', '3'], + }); + flush(); + expect(filePickerState.hasFilesSelected()).toBe(true); + expect(filePickerState.getSelectedFileIds()).toEqual(['1', '2', '3']); + }); + }); + it('updates when files are removed', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold(' --a-b---c|').pipe(tap((id) => filePickerState.selectFile(id))); + const removeFiles$ = cold('------a|').pipe(tap((id) => filePickerState.unselectFile(id))); + expectObservable(merge(addFiles$, removeFiles$)).toBe('--a-b-a-c|'); + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-d-e-', { + a: [], + b: ['a'], + c: ['a', 'b'], + d: ['b'], + e: ['b', 'c'], + }); + flush(); + expect(filePickerState.hasFilesSelected()).toBe(true); + expect(filePickerState.getSelectedFileIds()).toEqual(['b', 'c']); + }); + }); + it('does not add duplicates', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold('--a-b-a-a-a|').pipe(tap((id) => filePickerState.selectFile(id))); + expectObservable(addFiles$).toBe('--a-b-a-a-a|'); + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-d-e-f-', { + a: [], + b: ['a'], + c: ['a', 'b'], + d: ['a', 'b'], + e: ['a', 'b'], + f: ['a', 'b'], + }); + flush(); + expect(filePickerState.hasFilesSelected()).toBe(true); + expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); + }); + }); + it('calls the API with the expected args', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const files = [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + ] as FileJSON[]; + filesClient.list.mockImplementation(() => of({ files }) as any); + + const inputQuery = '-------a---b|'; + const inputPage = ' ---------------2|'; + + const query$ = cold(inputQuery).pipe(tap((q) => filePickerState.setQuery(q))); + expectObservable(query$).toBe(inputQuery); + + const page$ = cold(inputPage).pipe(tap((p) => filePickerState.setPage(+p))); + expectObservable(page$).toBe(inputPage); + + expectObservable(filePickerState.files$, '----^').toBe('----a--b---c---d', { + a: files, + b: files, + c: files, + d: files, + }); + + flush(); + expect(filesClient.list).toHaveBeenCalledTimes(4); + expect(filesClient.list).toHaveBeenNthCalledWith(1, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: undefined, + page: 1, + perPage: 20, + status: ['READY'], + }); + expect(filesClient.list).toHaveBeenNthCalledWith(2, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['*a*'], + page: 1, + perPage: 20, + status: ['READY'], + }); + expect(filesClient.list).toHaveBeenNthCalledWith(3, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['*b*'], + page: 1, + perPage: 20, + status: ['READY'], + }); + expect(filesClient.list).toHaveBeenNthCalledWith(4, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['*b*'], + page: 3, + perPage: 20, + status: ['READY'], + }); + }); + }); + it('cancels in flight requests', () => { + getTestScheduler().run(({ expectObservable, cold }) => { + filesClient.list.mockImplementationOnce(() => NEVER as any); + filesClient.list.mockImplementationOnce(() => of({ files: [], total: 0 }) as any); + const inputQuery = '------a|'; + const input$ = cold(inputQuery).pipe(tap((q) => filePickerState.setQuery(q))); + expectObservable(input$).toBe(inputQuery); + expectObservable(filePickerState.files$, '--^').toBe('------a-', { a: [] }); + expectObservable(filePickerState.loadingError$).toBe('a-b---c-', {}); + }); + }); +}); diff --git a/x-pack/plugins/files/public/components/file_picker/file_picker_state.ts b/x-pack/plugins/files/public/components/file_picker/file_picker_state.ts new file mode 100644 index 0000000000000..3ca6ee9ffca99 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker_state.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + map, + tap, + from, + switchMap, + Observable, + shareReplay, + debounceTime, + Subscription, + combineLatest, + BehaviorSubject, + distinctUntilChanged, +} from 'rxjs'; +import { FileJSON } from '../../../common'; +import { FilesClient } from '../../types'; + +function naivelyFuzzify(query: string): string { + return query.includes('*') ? query : `*${query}*`; +} + +export class FilePickerState { + /** + * Files the user has selected + */ + public readonly selectedFileIds$ = new BehaviorSubject<string[]>([]); + + public readonly isLoading$ = new BehaviorSubject<boolean>(true); + public readonly loadingError$ = new BehaviorSubject<undefined | Error>(undefined); + public readonly hasFiles$ = new BehaviorSubject<boolean>(false); + public readonly hasQuery$ = new BehaviorSubject<boolean>(false); + public readonly query$ = new BehaviorSubject<undefined | string>(undefined); + public readonly queryDebounced$ = this.query$.pipe(debounceTime(100)); + public readonly currentPage$ = new BehaviorSubject<number>(0); + public readonly totalPages$ = new BehaviorSubject<undefined | number>(undefined); + + /** + * This is how we keep a deduplicated list of file ids representing files a user + * has selected + */ + private readonly fileSet = new Set<string>(); + private readonly retry$ = new BehaviorSubject<void>(undefined); + private readonly subscriptions: Subscription[] = []; + private readonly internalIsLoading$ = new BehaviorSubject<boolean>(true); + + constructor( + private readonly client: FilesClient, + private readonly kind: string, + public readonly pageSize: number + ) { + this.subscriptions = [ + this.query$ + .pipe( + tap(() => this.setIsLoading(true)), + map((query) => Boolean(query)), + distinctUntilChanged() + ) + .subscribe(this.hasQuery$), + this.internalIsLoading$ + .pipe(debounceTime(100), distinctUntilChanged()) + .subscribe(this.isLoading$), + ]; + } + + /** + * File objects we have loaded on the front end, stored here so that it can + * easily be passed to all relevant UI. + * + * @note This is not explicitly kept in sync with the selected files! + * @note This is not explicitly kept in sync with the selected files! + */ + public readonly files$ = combineLatest([ + this.currentPage$.pipe(distinctUntilChanged()), + this.query$.pipe(distinctUntilChanged(), debounceTime(100)), + this.retry$, + ]).pipe( + switchMap(([page, query]) => this.sendRequest(page, query)), + tap(({ total }) => this.updateTotalPages({ total })), + tap(({ total }) => this.hasFiles$.next(Boolean(total))), + map(({ files }) => files), + shareReplay() + ); + + private updateTotalPages = ({ total }: { total: number }): void => { + this.totalPages$.next(Math.ceil(total / this.pageSize)); + }; + + private sendNextSelectedFiles() { + this.selectedFileIds$.next(this.getSelectedFileIds()); + } + + private setIsLoading(value: boolean) { + this.internalIsLoading$.next(value); + } + + public selectFile = (fileId: string | string[]): void => { + (Array.isArray(fileId) ? fileId : [fileId]).forEach((id) => this.fileSet.add(id)); + this.sendNextSelectedFiles(); + }; + + private abort: undefined | (() => void) = undefined; + private sendRequest = ( + page: number, + query: undefined | string + ): Observable<{ files: FileJSON[]; total: number }> => { + if (this.abort) this.abort(); + this.setIsLoading(true); + this.loadingError$.next(undefined); + + const abortController = new AbortController(); + this.abort = () => { + try { + abortController.abort(); + } catch (e) { + // ignore + } + }; + + const request$ = from( + this.client.list({ + kind: this.kind, + name: query ? [naivelyFuzzify(query)] : undefined, + page: page + 1, + status: ['READY'], + perPage: this.pageSize, + abortSignal: abortController.signal, + }) + ).pipe( + tap(() => { + this.setIsLoading(false); + this.abort = undefined; + }), + shareReplay() + ); + + request$.subscribe({ + error: (e: Error) => { + if (e.name === 'AbortError') return; + this.setIsLoading(false); + this.loadingError$.next(e); + }, + }); + + return request$; + }; + + public retry = (): void => { + this.retry$.next(); + }; + + public hasFilesSelected = (): boolean => { + return this.fileSet.size > 0; + }; + + public unselectFile = (fileId: string): void => { + if (this.fileSet.delete(fileId)) this.sendNextSelectedFiles(); + }; + + public isFileIdSelected = (fileId: string): boolean => { + return this.fileSet.has(fileId); + }; + + public getSelectedFileIds = (): string[] => { + return Array.from(this.fileSet); + }; + + public setQuery = (query: undefined | string): void => { + if (query) this.query$.next(query); + else this.query$.next(undefined); + this.currentPage$.next(0); + }; + + public setPage = (page: number): void => { + this.currentPage$.next(page); + }; + + public dispose = (): void => { + for (const sub of this.subscriptions) sub.unsubscribe(); + }; + + watchFileSelected$ = (id: string): Observable<boolean> => { + return this.selectedFileIds$.pipe( + map(() => this.fileSet.has(id)), + distinctUntilChanged() + ); + }; +} + +interface CreateFilePickerArgs { + client: FilesClient; + kind: string; + pageSize: number; +} +export const createFilePickerState = ({ + pageSize, + client, + kind, +}: CreateFilePickerArgs): FilePickerState => { + return new FilePickerState(client, kind, pageSize); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts b/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts new file mode 100644 index 0000000000000..2670ecd71b084 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const i18nTexts = { + title: i18n.translate('xpack.files.filePicker.title', { + defaultMessage: 'Select a file', + }), + loadingFilesErrorTitle: i18n.translate('xpack.files.filePicker.error.loadingTitle', { + defaultMessage: 'Could not load files', + }), + retryButtonLabel: i18n.translate('xpack.files.filePicker.error.retryButtonLabel', { + defaultMessage: 'Retry', + }), + emptyStatePrompt: i18n.translate('xpack.files.filePicker.emptyStatePrompt', { + defaultMessage: 'No files found', + }), + emptyStatePromptSubtitle: i18n.translate('xpack.files.filePicker.emptyStatePromptSubtitle', { + defaultMessage: 'Upload your first file.', + }), + selectFileLabel: i18n.translate('xpack.files.filePicker.selectFileButtonLable', { + defaultMessage: 'Select file', + }), + selectFilesLabel: (nrOfFiles: number) => + i18n.translate('xpack.files.filePicker.selectFilesButtonLable', { + defaultMessage: 'Select {nrOfFiles} files', + values: { nrOfFiles }, + }), + searchFieldPlaceholder: i18n.translate('xpack.files.filePicker.searchFieldPlaceholder', { + defaultMessage: 'my-file-*', + }), + emptyFileGridPrompt: i18n.translate('xpack.files.filePicker.emptyGridPrompt', { + defaultMessage: 'No files matched filter', + }), + loadMoreButtonLabel: i18n.translate('xpack.files.filePicker.loadMoreButtonLabel', { + defaultMessage: 'Load more', + }), + clearFilterButton: i18n.translate('xpack.files.filePicker.clearFilterButtonLabel', { + defaultMessage: 'Clear filter', + }), +}; diff --git a/x-pack/plugins/files/public/components/file_picker/index.tsx b/x-pack/plugins/files/public/components/file_picker/index.tsx new file mode 100644 index 0000000000000..47c892ef1cadd --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { Props } from './file_picker'; + +export type { Props as FilePickerProps }; + +const FilePickerContainer = lazy(() => import('./file_picker')); + +export const FilePicker = (props: Props) => ( + <Suspense fallback={<EuiLoadingSpinner size="xl" />}> + <FilePickerContainer {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/files/public/components/image/image.stories.tsx b/x-pack/plugins/files/public/components/image/image.stories.tsx index 02daf7badb329..ff8825485f9cb 100644 --- a/x-pack/plugins/files/public/components/image/image.stories.tsx +++ b/x-pack/plugins/files/public/components/image/image.stories.tsx @@ -13,6 +13,7 @@ import { FilesContext } from '../context'; import { getImageMetadata } from '../util'; import { Image, Props } from './image'; import { getImageData as getBlob, base64dLogo } from './image.constants.stories'; +import { FilesClient } from '../../types'; const defaultArgs: Props = { alt: 'test', src: `data:image/png;base64,${base64dLogo}` }; @@ -22,7 +23,7 @@ export default { args: defaultArgs, decorators: [ (Story) => ( - <FilesContext> + <FilesContext client={{} as unknown as FilesClient}> <Story /> </FilesContext> ), diff --git a/x-pack/plugins/files/public/components/image/image.tsx b/x-pack/plugins/files/public/components/image/image.tsx index 915f45c828f66..b83739d180c94 100644 --- a/x-pack/plugins/files/public/components/image/image.tsx +++ b/x-pack/plugins/files/public/components/image/image.tsx @@ -32,6 +32,15 @@ export interface Props extends ImgHTMLAttributes<HTMLImageElement> { * Emits when the image first becomes visible */ onFirstVisible?: () => void; + + /** + * As an optimisation images are only loaded when they are visible. + * This setting overrides this behavior and loads an image as soon as the + * component mounts. + * + * @default true + */ + lazy?: boolean; } /** @@ -46,13 +55,26 @@ export interface Props extends ImgHTMLAttributes<HTMLImageElement> { */ export const Image = React.forwardRef<HTMLImageElement, Props>( ( - { src, alt, onFirstVisible, onLoad, onError, meta, wrapperProps, size = 'original', ...rest }, + { + src, + alt, + onFirstVisible, + onLoad, + onError, + meta, + wrapperProps, + size = 'original', + lazy = true, + ...rest + }, ref ) => { const [isLoaded, setIsLoaded] = useState<boolean>(false); const [blurDelayExpired, setBlurDelayExpired] = useState(false); const { isVisible, ref: observerRef } = useViewportObserver({ onFirstVisible }); + const loadImage = lazy ? isVisible : true; + useEffect(() => { let unmounted = false; const id = window.setTimeout(() => { @@ -90,8 +112,8 @@ export const Image = React.forwardRef<HTMLImageElement, Props>( observerRef={observerRef} ref={ref} size={size} - hidden={!isVisible} - src={isVisible ? src : undefined} + hidden={!loadImage} + src={loadImage ? src : undefined} alt={alt} onLoad={(ev) => { setIsLoaded(true); diff --git a/x-pack/plugins/files/public/components/image/viewport_observer.ts b/x-pack/plugins/files/public/components/image/viewport_observer.ts index a73e0f4067881..c0efe3d095594 100644 --- a/x-pack/plugins/files/public/components/image/viewport_observer.ts +++ b/x-pack/plugins/files/public/components/image/viewport_observer.ts @@ -25,7 +25,10 @@ export class ViewportObserver { opts: IntersectionObserverInit ) => IntersectionObserver ) { - this.intersectionObserver = getIntersectionObserver(this.handleChange, { root: null }); + this.intersectionObserver = getIntersectionObserver(this.handleChange, { + rootMargin: '0px', + root: null, + }); } /** diff --git a/x-pack/plugins/files/public/components/index.ts b/x-pack/plugins/files/public/components/index.ts index 533b37505b961..c5ab1382b4dfa 100644 --- a/x-pack/plugins/files/public/components/index.ts +++ b/x-pack/plugins/files/public/components/index.ts @@ -7,4 +7,5 @@ export { Image, type ImageProps } from './image'; export { UploadFile, type UploadFileProps } from './upload_file'; +export { FilePicker, type FilePickerProps } from './file_picker'; export { FilesContext } from './context'; diff --git a/x-pack/plugins/files/public/components/stories_shared.ts b/x-pack/plugins/files/public/components/stories_shared.ts new file mode 100644 index 0000000000000..a82ec3295b1d0 --- /dev/null +++ b/x-pack/plugins/files/public/components/stories_shared.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FileKind } from '../../common'; +import { + setFileKindsRegistry, + getFileKindsRegistry, + FileKindsRegistryImpl, +} from '../../common/file_kinds_registry'; + +setFileKindsRegistry(new FileKindsRegistryImpl()); +const fileKindsRegistry = getFileKindsRegistry(); +export const register: FileKindsRegistryImpl['register'] = (fileKind: FileKind) => { + if (!fileKindsRegistry.getAll().find((kind) => kind.id === fileKind.id)) { + getFileKindsRegistry().register(fileKind); + } +}; diff --git a/x-pack/plugins/files/public/components/upload_file/upload_file.component.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.component.tsx index f4f5986d2f00b..37c5673284857 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_file.component.tsx +++ b/x-pack/plugins/files/public/components/upload_file/upload_file.component.tsx @@ -25,6 +25,8 @@ import { useUploadState } from './context'; export interface Props { meta?: unknown; accept?: string; + multiple?: boolean; + fullWidth?: boolean; immediate?: boolean; allowClear?: boolean; initialFilePromptText?: string; @@ -33,7 +35,10 @@ export interface Props { const { euiFormMaxWidth, euiButtonHeightSmall } = euiThemeVars; export const UploadFile = React.forwardRef<EuiFilePicker, Props>( - ({ meta, accept, immediate, allowClear = false, initialFilePromptText }, ref) => { + ( + { meta, accept, immediate, allowClear = false, multiple, initialFilePromptText, fullWidth }, + ref + ) => { const uploadState = useUploadState(); const uploading = useBehaviorSubject(uploadState.uploading$); const error = useBehaviorSubject(uploadState.error$); @@ -48,10 +53,11 @@ export const UploadFile = React.forwardRef<EuiFilePicker, Props>( <div data-test-subj="filesUploadFile" css={css` - max-width: ${euiFormMaxWidth}; + max-width: ${fullWidth ? '100%' : euiFormMaxWidth}; `} > <EuiFilePicker + fullWidth={fullWidth} aria-label={i18nTexts.defaultPickerLabel} id={id} ref={ref} @@ -59,7 +65,7 @@ export const UploadFile = React.forwardRef<EuiFilePicker, Props>( uploadState.setFiles(Array.from(fs ?? [])); if (immediate) uploadState.upload(meta); }} - multiple={false} + multiple={multiple} initialPromptText={initialFilePromptText} isLoading={uploading} isInvalid={isInvalid} @@ -75,6 +81,7 @@ export const UploadFile = React.forwardRef<EuiFilePicker, Props>( alignItems="flexStart" direction="rowReverse" gutterSize="m" + responsive={false} > <EuiFlexItem grow={false}> <ControlButton diff --git a/x-pack/plugins/files/public/components/upload_file/upload_file.stories.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.stories.tsx index c5a64d6d91a52..f842a5e0d288f 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_file.stories.tsx +++ b/x-pack/plugins/files/public/components/upload_file/upload_file.stories.tsx @@ -5,14 +5,10 @@ * 2.0. */ import React from 'react'; -import { ComponentStory } from '@storybook/react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { - FileKindsRegistryImpl, - setFileKindsRegistry, - getFileKindsRegistry, -} from '../../../common/file_kinds_registry'; +import { register } from '../stories_shared'; import { FilesClient } from '../../types'; import { FilesContext } from '../context'; import { UploadFile, Props } from './upload_file'; @@ -24,28 +20,36 @@ const defaultArgs: Props = { kind, onDone: action('onDone'), onError: action('onError'), - client: { - create: async () => ({ file: { id: 'test' } }), - upload: () => sleep(1000), - } as unknown as FilesClient, }; export default { title: 'stateful/UploadFile', component: UploadFile, args: defaultArgs, -}; - -setFileKindsRegistry(new FileKindsRegistryImpl()); - -getFileKindsRegistry().register({ + decorators: [ + (Story) => ( + <FilesContext + client={ + { + create: async () => ({ file: { id: 'test' } }), + upload: () => sleep(1000), + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), + ], +} as ComponentMeta<typeof UploadFile>; + +register({ id: kind, http: {}, allowedMimeTypes: ['*'], }); const miniFile = 'miniFile'; -getFileKindsRegistry().register({ +register({ id: miniFile, http: {}, maxSizeBytes: 1, @@ -53,17 +57,13 @@ getFileKindsRegistry().register({ }); const zipOnly = 'zipOnly'; -getFileKindsRegistry().register({ +register({ id: zipOnly, http: {}, allowedMimeTypes: ['application/zip'], }); -const Template: ComponentStory<typeof UploadFile> = (props: Props) => ( - <FilesContext> - <UploadFile {...props} /> - </FilesContext> -); +const Template: ComponentStory<typeof UploadFile> = (props: Props) => <UploadFile {...props} />; export const Basic = Template.bind({}); @@ -73,27 +73,43 @@ AllowRepeatedUploads.args = { }; export const LongErrorUX = Template.bind({}); -LongErrorUX.args = { - client: { - create: async () => ({ file: { id: 'test' } }), - upload: async () => { - await sleep(1000); - throw new Error('Something went wrong while uploading! '.repeat(10).trim()); - }, - delete: async () => {}, - } as unknown as FilesClient, -}; +LongErrorUX.decorators = [ + (Story) => ( + <FilesContext + client={ + { + create: async () => ({ file: { id: 'test' } }), + upload: async () => { + await sleep(1000); + throw new Error('Something went wrong while uploading! '.repeat(10).trim()); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; export const Abort = Template.bind({}); -Abort.args = { - client: { - create: async () => ({ file: { id: 'test' } }), - upload: async () => { - await sleep(60000); - }, - delete: async () => {}, - } as unknown as FilesClient, -}; +Abort.decorators = [ + (Story) => ( + <FilesContext + client={ + { + create: async () => ({ file: { id: 'test' } }), + upload: async () => { + await sleep(60000); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; export const MaxSize = Template.bind({}); MaxSize.args = { @@ -118,24 +134,44 @@ ImmediateUpload.args = { export const ImmediateUploadError = Template.bind({}); ImmediateUploadError.args = { immediate: true, - client: { - create: async () => ({ file: { id: 'test' } }), - upload: async () => { - await sleep(1000); - throw new Error('Something went wrong while uploading!'); - }, - delete: async () => {}, - } as unknown as FilesClient, }; +ImmediateUploadError.decorators = [ + (Story) => ( + <FilesContext + client={ + { + create: async () => ({ file: { id: 'test' } }), + upload: async () => { + await sleep(1000); + throw new Error('Something went wrong while uploading!'); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; export const ImmediateUploadAbort = Template.bind({}); +ImmediateUploadAbort.decorators = [ + (Story) => ( + <FilesContext + client={ + { + create: async () => ({ file: { id: 'test' } }), + upload: async () => { + await sleep(60000); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; ImmediateUploadAbort.args = { immediate: true, - client: { - create: async () => ({ file: { id: 'test' } }), - upload: async () => { - await sleep(60000); - }, - delete: async () => {}, - } as unknown as FilesClient, }; diff --git a/x-pack/plugins/files/public/components/upload_file/upload_file.test.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.test.tsx index 1812f74e180e3..826385d972afc 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_file.test.tsx +++ b/x-pack/plugins/files/public/components/upload_file/upload_file.test.tsx @@ -30,7 +30,7 @@ describe('UploadFile', () => { async function initTestBed(props?: Partial<Props>) { const createTestBed = registerTestBed((p: Props) => ( - <FilesContext> + <FilesContext client={client}> <UploadFile {...p} /> </FilesContext> )); diff --git a/x-pack/plugins/files/public/components/upload_file/upload_file.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.tsx index e85460ca7c1e3..7e049094075ce 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_file.tsx +++ b/x-pack/plugins/files/public/components/upload_file/upload_file.tsx @@ -7,7 +7,6 @@ import { EuiFilePicker } from '@elastic/eui'; import React, { type FunctionComponent, useRef, useEffect, useMemo } from 'react'; -import { FilesClient } from '../../types'; import { useFilesContext } from '../context'; @@ -37,10 +36,6 @@ export interface Props<Kind extends string = string> { * A file kind that should be registered during plugin startup. See {@link FileServiceStart}. */ kind: Kind; - /** - * A files client that will be used process uploads. - */ - client: FilesClient<any>; /** * Allow users to clear a file after uploading. * @@ -56,6 +51,10 @@ export interface Props<Kind extends string = string> { * Metadata that you want to associate with any uploaded files */ meta?: Record<string, unknown>; + /** + * Whether to display the file picker with width 100%; + */ + fullWidth?: boolean; /** * Whether this component should display a "done" state after processing an * upload or return to the initial state to allow for another upload. @@ -63,6 +62,10 @@ export interface Props<Kind extends string = string> { * @default false */ allowRepeatedUploads?: boolean; + /** + * The initial text prompt + */ + initialPromptText?: string; /** * Called when the an upload process fully completes */ @@ -72,6 +75,11 @@ export interface Props<Kind extends string = string> { * Called when an error occurs during upload */ onError?: (e: Error) => void; + + /** + * Allow upload more than one file at a time + */ + multiple?: boolean; } /** @@ -82,15 +90,17 @@ export interface Props<Kind extends string = string> { */ export const UploadFile = <Kind extends string = string>({ meta, - client, onDone, onError, + fullWidth, allowClear, kind: kindId, + multiple = false, + initialPromptText, immediate = false, allowRepeatedUploads = false, }: Props<Kind>): ReturnType<FunctionComponent> => { - const { registry } = useFilesContext(); + const { registry, client } = useFilesContext(); const ref = useRef<null | EuiFilePicker>(null); const fileKind = registry.get(kindId); const uploadState = useMemo( @@ -127,6 +137,9 @@ export const UploadFile = <Kind extends string = string>({ meta={meta} immediate={immediate} allowClear={allowClear} + fullWidth={fullWidth} + initialFilePromptText={initialPromptText} + multiple={multiple} /> </context.Provider> ); diff --git a/x-pack/plugins/files/public/components/util/image_metadata.ts b/x-pack/plugins/files/public/components/util/image_metadata.ts index 9358dda9d05ad..9c6e74f4c0101 100644 --- a/x-pack/plugins/files/public/components/util/image_metadata.ts +++ b/x-pack/plugins/files/public/components/util/image_metadata.ts @@ -8,8 +8,8 @@ import * as bh from 'blurhash'; import type { FileImageMetadata } from '../../../common'; -export function isImage(file: Blob | File): boolean { - return file.type?.startsWith('image/'); +export function isImage(file: { type?: string }): boolean { + return Boolean(file.type?.startsWith('image/')); } export const boxDimensions = { diff --git a/x-pack/plugins/files/public/files_client/files_client.ts b/x-pack/plugins/files/public/files_client/files_client.ts index a17929a4a100b..f92b2c91d2796 100644 --- a/x-pack/plugins/files/public/files_client/files_client.ts +++ b/x-pack/plugins/files/public/files_client/files_client.ts @@ -102,11 +102,12 @@ export function createFilesClient({ getById: ({ kind, ...args }) => { return http.get(apiRoutes.getByIdRoute(scopedFileKind ?? kind, args.id)); }, - list: ({ kind, page, perPage, ...body } = { kind: '' }) => { + list: ({ kind, page, perPage, abortSignal, ...body } = { kind: '' }) => { return http.post(apiRoutes.getListRoute(scopedFileKind ?? kind), { headers: commonBodyHeaders, query: { page, perPage }, body: JSON.stringify(body), + signal: abortSignal, }); }, update: ({ kind, id, ...body }) => { diff --git a/x-pack/plugins/files/public/index.ts b/x-pack/plugins/files/public/index.ts index f08b073e7485b..a9bd88615ff12 100644 --- a/x-pack/plugins/files/public/index.ts +++ b/x-pack/plugins/files/public/index.ts @@ -19,6 +19,8 @@ export { type ImageProps, UploadFile, type UploadFileProps, + FilePicker, + type FilePickerProps, } from './components'; export function plugin() { diff --git a/x-pack/plugins/files/public/types.ts b/x-pack/plugins/files/public/types.ts index 1cc69ac4ed23e..fcc5c11b1ae45 100644 --- a/x-pack/plugins/files/public/types.ts +++ b/x-pack/plugins/files/public/types.ts @@ -25,7 +25,9 @@ import type { } from '../common/api_routes'; type UnscopedClientMethodFrom<E extends HttpApiInterfaceEntryDefinition> = ( - args: E['inputs']['body'] & E['inputs']['params'] & E['inputs']['query'] + args: E['inputs']['body'] & + E['inputs']['params'] & + E['inputs']['query'] & { abortSignal?: AbortSignal } ) => Promise<E['output']>; /**