diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index afdf8be1f4f6e..9d10e33a8b23d 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -169,6 +169,11 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = {showFilePickerModal && ( setShowFilePickerModal(false)} + onUpload={() => { + notifications.toasts.addSuccess({ + title: 'Uploaded files', + }); + }} onDone={(ids) => { notifications.toasts.addSuccess({ title: 'Selected files!', diff --git a/x-pack/examples/files_example/public/components/file_picker.tsx b/x-pack/examples/files_example/public/components/file_picker.tsx index 3c2178b299ea2..bc30ab92654d8 100644 --- a/x-pack/examples/files_example/public/components/file_picker.tsx +++ b/x-pack/examples/files_example/public/components/file_picker.tsx @@ -14,9 +14,18 @@ import { FilePicker } from '../imports'; interface Props { onClose: () => void; + onUpload: (ids: string[]) => void; onDone: (ids: string[]) => void; } -export const MyFilePicker: FunctionComponent = ({ onClose, onDone }) => { - return ; +export const MyFilePicker: FunctionComponent = ({ onClose, onDone, onUpload }) => { + return ( + onUpload(n.map(({ id }) => id))} + pageSize={50} + /> + ); }; 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 index 14356b9b02bd4..8ce3383c7e852 100644 --- 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 @@ -12,6 +12,7 @@ import { css } from '@emotion/react'; import { useFilePickerContext } from '../context'; import { i18nTexts } from '../i18n_texts'; +import { useBehaviorSubject } from '../../use_behavior_subject'; interface Props { onClick: () => void; @@ -19,6 +20,7 @@ interface Props { export const ClearFilterButton: FunctionComponent = ({ onClick }) => { const { state } = useFilePickerContext(); + const isUploading = useBehaviorSubject(state.isUploading$); const query = useObservable(state.queryDebounced$); if (!query) { return null; @@ -30,7 +32,9 @@ export const ClearFilterButton: FunctionComponent = ({ onClick }) => { place-items: center; `} > - {i18nTexts.clearFilterButton} + + {i18nTexts.clearFilterButton} + ); }; 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 index 88c77f36a6c00..89d8e84c0397c 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import type { FunctionComponent } from 'react'; import numeral from '@elastic/numeral'; import useObservable from 'react-use/lib/useObservable'; @@ -26,8 +26,8 @@ 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 isSelected$ = useMemo(() => state.watchFileSelected$(file.id), [file.id, state]); + const isSelected = useObservable(isSelected$, false); const imageHeight = `calc(${euiTheme.size.xxxl} * 2)`; return ( 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 index d0d0e146d2c3b..643efec8abebd 100644 --- 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 @@ -5,24 +5,72 @@ * 2.0. */ -import { EuiFlexGroup, EuiModalFooter } from '@elastic/eui'; +import { EuiModalFooter } from '@elastic/eui'; +import { css } from '@emotion/react'; import type { FunctionComponent } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { UploadFile } from '../../upload_file'; +import type { Props as FilePickerProps } from '../file_picker'; +import { useFilePickerContext } from '../context'; +import { i18nTexts } from '../i18n_texts'; import { Pagination } from './pagination'; import { SelectButton, Props as SelectButtonProps } from './select_button'; interface Props { + kind: string; onDone: SelectButtonProps['onClick']; + onUpload?: FilePickerProps['onUpload']; } -export const ModalFooter: FunctionComponent = ({ onDone }) => { +export const ModalFooter: FunctionComponent = ({ kind, onDone, onUpload }) => { + const { state } = useFilePickerContext(); + const onUploadStart = useCallback(() => state.setIsUploading(true), [state]); + const onUploadEnd = useCallback(() => state.setIsUploading(false), [state]); return ( - - - - +
+
+ { + state.selectFile(n.map(({ id }) => id)); + state.resetFilters(); + onUpload?.(n); + }} + onUploadStart={onUploadStart} + onUploadEnd={onUploadEnd} + kind={kind} + initialPromptText={i18nTexts.uploadFilePlaceholderText} + multiple + compressed + /> +
+
+ +
+
+ +
+
); }; 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 index bc2d0d444ba45..397a1cda06e48 100644 --- a/x-pack/plugins/files/public/components/file_picker/components/pagination.tsx +++ b/x-pack/plugins/files/public/components/file_picker/components/pagination.tsx @@ -8,12 +8,25 @@ import React from 'react'; import type { FunctionComponent } from 'react'; import { EuiPagination } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; import { useFilePickerContext } from '../context'; import { useBehaviorSubject } from '../../use_behavior_subject'; export const Pagination: FunctionComponent = () => { const { state } = useFilePickerContext(); const page = useBehaviorSubject(state.currentPage$); + const files = useObservable(state.files$, []); const pageCount = useBehaviorSubject(state.totalPages$); - return ; + const isUploading = useBehaviorSubject(state.isUploading$); + if (files.length === 0) { + return null; + } + return ( + {} : state.setPage} + pageCount={pageCount} + activePage={page} + /> + ); }; 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 index 0235b03dd3fc1..bb1fe3580d2bb 100644 --- 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 @@ -17,10 +17,11 @@ export const SearchField: FunctionComponent = () => { const query = useBehaviorSubject(state.query$); const isLoading = useBehaviorSubject(state.isLoading$); const hasFiles = useBehaviorSubject(state.hasFiles$); + const isUploading = useBehaviorSubject(state.isUploading$); return ( = ({ onClick }) => { const { state } = useFilePickerContext(); + const isUploading = useBehaviorSubject(state.isUploading$); const selectedFiles = useBehaviorSubject(state.selectedFileIds$); return ( onClick(selectedFiles)} > {selectedFiles.length > 1 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 index 14b621050a0ef..b9005c89503f3 100644 --- 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 @@ -50,6 +50,7 @@ describe('FilePicker', () => { selectButton: `${baseTestSubj}.selectButton`, loadingSpinner: `${baseTestSubj}.loadingSpinner`, fileGrid: `${baseTestSubj}.fileGrid`, + paginationControls: `${baseTestSubj}.paginationControls`, }; return { @@ -126,4 +127,10 @@ describe('FilePicker', () => { expect(onDone).toHaveBeenCalledTimes(1); expect(onDone).toHaveBeenNthCalledWith(1, ['a', 'b']); }); + it('hides pagination if there are no files', async () => { + client.list.mockImplementation(() => Promise.resolve({ files: [] as FileJSON[], total: 2 })); + const { actions, testSubjects, exists } = await initTestBed(); + await actions.waitUntilLoaded(); + expect(exists(testSubjects.paginationControls)).toBe(false); + }); }); 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 index 72920b72a865d..6c95cd225ae27 100644 --- a/x-pack/plugins/files/public/components/file_picker/file_picker.tsx +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.tsx @@ -17,6 +17,7 @@ import { EuiFlexGroup, } from '@elastic/eui'; +import type { DoneNotification } from '../upload_file'; import { useBehaviorSubject } from '../use_behavior_subject'; import { useFilePickerContext, FilePickerContext } from './context'; @@ -43,13 +44,17 @@ export interface Props { * Will be called after a user has a selected a set of files */ onDone: (fileIds: string[]) => void; + /** + * When a user has succesfully uploaded some files this callback will be called + */ + onUpload?: (done: DoneNotification[]) => void; /** * The number of results to show per page. */ pageSize?: number; } -const Component: FunctionComponent = ({ onClose, onDone }) => { +const Component: FunctionComponent = ({ onClose, onDone, onUpload }) => { const { state, kind } = useFilePickerContext(); const hasFiles = useBehaviorSubject(state.hasFiles$); @@ -59,7 +64,7 @@ const Component: FunctionComponent = ({ onClose, onDone }) => { useObservable(state.files$); - const renderFooter = () => ; + const renderFooter = () => ; return ( { expectObservable(filePickerState.loadingError$).toBe('a-b---c-', {}); }); }); + it('does not allow fetching files while an upload is in progress', () => { + getTestScheduler().run(({ expectObservable, cold }) => { + const files = [] as FileJSON[]; + filesClient.list.mockImplementation(() => of({ files }) as any); + const uploadInput = '---a|'; + const queryInput = ' -----a|'; + const upload$ = cold(uploadInput).pipe(tap(() => filePickerState.setIsUploading(true))); + const query$ = cold(queryInput).pipe(tap((q) => filePickerState.setQuery(q))); + expectObservable(merge(upload$, query$)).toBe('---a-a|'); + expectObservable(filePickerState.files$).toBe('a------', { a: [] }); + }); + }); }); 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 index 697f1fc58188d..708288f9cb4df 100644 --- 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 @@ -8,7 +8,9 @@ import { map, tap, from, + EMPTY, switchMap, + catchError, Observable, shareReplay, debounceTime, @@ -17,8 +19,8 @@ import { BehaviorSubject, distinctUntilChanged, } from 'rxjs'; -import { FileJSON } from '../../../common'; -import { FilesClient } from '../../types'; +import type { FileJSON } from '../../../common'; +import type { FilesClient } from '../../types'; function naivelyFuzzify(query: string): string { return query.includes('*') ? query : `*${query}*`; @@ -38,6 +40,7 @@ export class FilePickerState { public readonly queryDebounced$ = this.query$.pipe(debounceTime(100)); public readonly currentPage$ = new BehaviorSubject(0); public readonly totalPages$ = new BehaviorSubject(undefined); + public readonly isUploading$ = new BehaviorSubject(false); /** * This is how we keep a deduplicated list of file ids representing files a user @@ -56,23 +59,22 @@ export class FilePickerState { this.subscriptions = [ this.query$ .pipe( - tap(() => this.setIsLoading(true)), map((query) => Boolean(query)), distinctUntilChanged() ) .subscribe(this.hasQuery$), - this.requests$.pipe(tap(() => this.setIsLoading(true))).subscribe(), - this.internalIsLoading$ - .pipe(debounceTime(100), distinctUntilChanged()) - .subscribe(this.isLoading$), + this.internalIsLoading$.pipe(distinctUntilChanged()).subscribe(this.isLoading$), ]; } private readonly requests$ = combineLatest([ this.currentPage$.pipe(distinctUntilChanged()), - this.query$.pipe(distinctUntilChanged(), debounceTime(100)), + this.query$.pipe(distinctUntilChanged()), this.retry$, - ]); + ]).pipe( + tap(() => this.setIsLoading(true)), // set loading state as early as possible + debounceTime(100) + ); /** * File objects we have loaded on the front end, stored here so that it can @@ -111,6 +113,7 @@ export class FilePickerState { page: number, query: undefined | string ): Observable<{ files: FileJSON[]; total: number }> => { + if (this.isUploading$.getValue()) return EMPTY; if (this.abort) this.abort(); this.setIsLoading(true); this.loadingError$.next(undefined); @@ -134,6 +137,15 @@ export class FilePickerState { abortSignal: abortController.signal, }) ).pipe( + catchError((e) => { + if (e.name !== 'AbortError') { + this.setIsLoading(false); + this.loadingError$.next(e); + } else { + // If the request was aborted, we assume another request is now in progress + } + return EMPTY; + }), tap(() => { this.setIsLoading(false); this.abort = undefined; @@ -141,13 +153,7 @@ export class FilePickerState { shareReplay() ); - request$.subscribe({ - error: (e: Error) => { - if (e.name === 'AbortError') return; - this.setIsLoading(false); - this.loadingError$.next(e); - }, - }); + request$.subscribe(); return request$; }; @@ -156,6 +162,12 @@ export class FilePickerState { this.retry$.next(); }; + public resetFilters = (): void => { + this.setQuery(undefined); + this.setPage(0); + this.retry(); + }; + public hasFilesSelected = (): boolean => { return this.fileSet.size > 0; }; @@ -182,6 +194,10 @@ export class FilePickerState { this.currentPage$.next(page); }; + public setIsUploading = (value: boolean): void => { + this.isUploading$.next(value); + }; + public dispose = (): void => { for (const sub of this.subscriptions) sub.unsubscribe(); }; 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 index 2670ecd71b084..59ea5457ec6c4 100644 --- a/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts +++ b/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts @@ -43,4 +43,7 @@ export const i18nTexts = { clearFilterButton: i18n.translate('xpack.files.filePicker.clearFilterButtonLabel', { defaultMessage: 'Clear filter', }), + uploadFilePlaceholderText: i18n.translate('xpack.files.filePicker.uploadFilePlaceholderText', { + defaultMessage: 'Drag and drop to upload new files', + }), }; diff --git a/x-pack/plugins/files/public/components/upload_file/index.tsx b/x-pack/plugins/files/public/components/upload_file/index.tsx index 4901c46a78c91..8e9e89c33c799 100644 --- a/x-pack/plugins/files/public/components/upload_file/index.tsx +++ b/x-pack/plugins/files/public/components/upload_file/index.tsx @@ -9,6 +9,7 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import type { Props } from './upload_file'; +export type { DoneNotification } from './upload_state'; export type { Props as UploadFileProps }; const UploadFileContainer = lazy(() => import('./upload_file')); 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 d0ca3577b27a0..ae23739afbf2e 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 @@ -76,6 +76,16 @@ export interface Props { */ onError?: (e: Error) => void; + /** + * Will be called whenever an upload starts + */ + onUploadStart?: () => void; + + /** + * Will be called when attempt ends, in error otherwise + */ + onUploadEnd?: () => void; + /** * Whether to display the component in it's compact form. * @@ -105,6 +115,8 @@ export const UploadFile = ({ onError, fullWidth, allowClear, + onUploadEnd, + onUploadStart, compressed = false, kind: kindId, multiple = false, @@ -136,9 +148,12 @@ export const UploadFile = ({ }), uploadState.done$.subscribe((n) => n && onDone(n)), uploadState.error$.subscribe((e) => e && onError?.(e)), + uploadState.uploading$.subscribe((uploading) => + uploading ? onUploadStart?.() : onUploadEnd?.() + ), ]; return () => subs.forEach((sub) => sub.unsubscribe()); - }, [uploadState, onDone, onError]); + }, [uploadState, onDone, onError, onUploadStart, onUploadEnd]); useEffect(() => uploadState.dispose, [uploadState]); diff --git a/x-pack/plugins/files/public/components/upload_file/upload_state.ts b/x-pack/plugins/files/public/components/upload_file/upload_state.ts index 13c4cc020b05a..061f65115c799 100644 --- a/x-pack/plugins/files/public/components/upload_file/upload_state.ts +++ b/x-pack/plugins/files/public/components/upload_file/upload_state.ts @@ -43,7 +43,7 @@ interface FileState { type Upload = SimpleStateSubject; -interface DoneNotification { +export interface DoneNotification { id: string; kind: string; } diff --git a/x-pack/plugins/files/server/routes/common_schemas.ts b/x-pack/plugins/files/server/routes/common_schemas.ts index 6f9b6a5d651a0..449e4995ac5dd 100644 --- a/x-pack/plugins/files/server/routes/common_schemas.ts +++ b/x-pack/plugins/files/server/routes/common_schemas.ts @@ -42,4 +42,7 @@ export const fileAlt = schema.maybe( }) ); +export const page = schema.number({ min: 1, defaultValue: 1 }); +export const pageSize = schema.number({ min: 1, defaultValue: 100 }); + export const fileMeta = schema.maybe(schema.object({}, { unknowns: 'allow' })); diff --git a/x-pack/plugins/files/server/routes/file_kind/list.ts b/x-pack/plugins/files/server/routes/file_kind/list.ts index b9b389f41b7a9..96d1d8cc27273 100644 --- a/x-pack/plugins/files/server/routes/file_kind/list.ts +++ b/x-pack/plugins/files/server/routes/file_kind/list.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import type { FileJSON, FileKind } from '../../../common/types'; import { CreateRouteDefinition, FILES_API_ROUTES } from '../api_routes'; +import * as cs from '../common_schemas'; import type { CreateHandler, FileKindRouter } from './types'; import { stringOrArrayOfStrings, @@ -24,8 +25,8 @@ const rt = { meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), query: schema.object({ - page: schema.maybe(schema.number()), - perPage: schema.maybe(schema.number({ defaultValue: 100 })), + page: schema.maybe(cs.page), + perPage: schema.maybe(cs.pageSize), }), }; diff --git a/x-pack/plugins/files/server/routes/file_kind/share/list.ts b/x-pack/plugins/files/server/routes/file_kind/share/list.ts index edd58dbed7b6e..91a893d2dd31a 100644 --- a/x-pack/plugins/files/server/routes/file_kind/share/list.ts +++ b/x-pack/plugins/files/server/routes/file_kind/share/list.ts @@ -9,13 +9,14 @@ import { schema } from '@kbn/config-schema'; import { CreateRouteDefinition, FILES_API_ROUTES } from '../../api_routes'; import type { FileKind, FileShareJSON } from '../../../../common/types'; import { CreateHandler, FileKindRouter } from '../types'; +import * as cs from '../../common_schemas'; export const method = 'get' as const; const rt = { query: schema.object({ - page: schema.maybe(schema.number()), - perPage: schema.maybe(schema.number()), + page: schema.maybe(cs.page), + perPage: schema.maybe(cs.pageSize), forFileId: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/files/server/routes/find.ts b/x-pack/plugins/files/server/routes/find.ts index 9ec5ab681cb66..80a398189dae3 100644 --- a/x-pack/plugins/files/server/routes/find.ts +++ b/x-pack/plugins/files/server/routes/find.ts @@ -9,6 +9,7 @@ import type { CreateHandler, FilesRouter } from './types'; import { FileJSON } from '../../common'; import { FILES_MANAGE_PRIVILEGE } from '../../common/constants'; import { FILES_API_ROUTES, CreateRouteDefinition } from './api_routes'; +import { page, pageSize } from './common_schemas'; const method = 'post' as const; @@ -32,8 +33,8 @@ const rt = { meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), query: schema.object({ - page: schema.maybe(schema.number()), - perPage: schema.maybe(schema.number({ defaultValue: 100 })), + page: schema.maybe(page), + perPage: schema.maybe(pageSize), }), };