From f18409647e3bcade33598f771f03bef2425b8399 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 12:26:01 +0200 Subject: [PATCH 01/77] added unscoped file client to the files components context --- x-pack/examples/files_example/public/application.tsx | 2 +- x-pack/plugins/files/public/components/context.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/plugins/files/public/components/context.tsx b/x-pack/plugins/files/public/components/context.tsx index e55c0c45e4da6..3587a36205928 100644 --- a/x-pack/plugins/files/public/components/context.tsx +++ b/x-pack/plugins/files/public/components/context.tsx @@ -7,9 +7,11 @@ 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; + client: FilesClient; } const FilesContextObject = createContext(null as unknown as FilesContextValue); @@ -21,10 +23,14 @@ export const useFilesContext = () => { } return ctx; }; -export const FilesContext: FunctionComponent = ({ children }) => { +export const FilesContext: FunctionComponent<{ client: FilesClient }> = ({ + client, + children, +}) => { return ( From ddf2173e8e8d0a0b35b7062bdc04b2d3ae284597 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 12:26:26 +0200 Subject: [PATCH 02/77] wip: created some basic files and stories for new filepicker component --- .../file_picker/file_picker.stories.tsx | 32 +++++++++++++++++++ .../components/file_picker/file_picker.tsx | 20 ++++++++++++ .../public/components/file_picker/index.ts | 6 ++++ 3 files changed, 58 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.stories.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/index.ts 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..c55dd1b729a34 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.stories.tsx @@ -0,0 +1,32 @@ +/* + * 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 { FilesContext } from '../context'; +import { FilePicker } from './file_picker'; + +export default { + title: 'components/FilePicker', + component: FilePicker, + args: {}, + decorators: [ + (Story) => ( + + + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (props) => ( + + + +); + +export const Basic = Template.bind({}); 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..5965ad9b7a23d --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.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 { useQuery } from '@tanstack/react-query'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { UploadFile } from '../upload_file'; + +import { useFilesContext } from '../context'; + +export interface Props { + kind: Kind; +} + +export const FilePicker: FunctionComponent = ({}) => { + return

OK

; +}; diff --git a/x-pack/plugins/files/public/components/file_picker/index.ts b/x-pack/plugins/files/public/components/file_picker/index.ts new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/index.ts @@ -0,0 +1,6 @@ +/* + * 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. + */ From 41ed423041f2ed7beac3ebff553562dc4d0cc804 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 12:49:37 +0200 Subject: [PATCH 03/77] fix some types after require filesclient to be passed in --- .../public/components/file_picker/file_picker.stories.tsx | 2 +- x-pack/plugins/files/public/components/image/image.stories.tsx | 3 ++- .../public/components/upload_file/upload_file.stories.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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 index c55dd1b729a34..decea8fc3f1ba 100644 --- 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 @@ -24,7 +24,7 @@ export default { } as ComponentMeta; const Template: ComponentStory = (props) => ( - + ); 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) => ( - + ), 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..973f9f15112ef 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 @@ -60,7 +60,7 @@ getFileKindsRegistry().register({ }); const Template: ComponentStory = (props: Props) => ( - + ); From a85a1dc67a5fcab827d141313d6222c0529ea8c2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:33:53 +0200 Subject: [PATCH 04/77] added file picker state and some basic tests --- .../file_picker/file_picker_state.test.ts | 42 +++++++++++++++++++ .../file_picker/file_picker_state.ts | 39 +++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker_state.test.ts create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker_state.ts 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..6393f68c08ddd --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker_state.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { TestScheduler } from 'rxjs/testing'; +import { tap } from 'rxjs'; +import { FilePickerState } from './file_picker_state'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => expect(actual).toEqual(expected)); + +describe('FilePickerState', () => { + let filePickerState: FilePickerState; + beforeEach(() => { + filePickerState = new FilePickerState(); + }); + it('starts off empty', () => { + expect(filePickerState.isEmpty()).toBe(true); + }); + it('updates when files are added', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold('--a-b|').pipe(tap((id) => filePickerState.addFile(id))); + expectObservable(addFiles$).toBe('--a-b|'); + expectObservable(filePickerState.fileIds$).toBe('a-b-c-', { + a: [], + b: ['a'], + c: ['a', 'b'], + }); + expectObservable(filePickerState.size$).toBe('a-b-c-', { + a: 0, + b: 1, + c: 2, + }); + flush(); + expect(filePickerState.isEmpty()).toBe(false); + expect(filePickerState.getFileIds()).toEqual(['a', 'b']); + }); + }); +}); 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..f712161b6a154 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker_state.ts @@ -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 { BehaviorSubject } from 'rxjs'; + +export class FilePickerState { + private readonly fileSet = new Set(); + + private sendNext() { + this.size$.next(this.fileSet.size); + this.fileIds$.next(this.getFileIds()); + } + public fileIds$ = new BehaviorSubject([]); + public size$ = new BehaviorSubject(0); + + public isEmpty() { + return this.fileSet.size === 0; + } + + public addFile = (fileId: string): void => { + this.fileSet.add(fileId); + this.sendNext(); + }; + + public removeFile = (fileId: string): void => { + if (this.fileSet.delete(fileId)) this.sendNext(); + }; + + public hasFileId = (fileId: string): boolean => { + return this.fileSet.has(fileId); + }; + + public getFileIds = (): string[] => { + return Array.from(this.fileSet); + }; +} From 2661683fd259147f5010684b0afd4a8dafea8561 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:34:24 +0200 Subject: [PATCH 05/77] added file picker context --- .../public/components/file_picker/context.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/context.tsx 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..b2eed1a1b0317 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -0,0 +1,15 @@ +/* + * 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 { QueryClientProvider, QueryClient } from '@tanstack/react-query'; + +const client = new QueryClient(); +export const FilePickerContext: FunctionComponent = ({ children }) => ( + {children} +); From a2221d99ed437b4f424ee23716842582ed6dea51 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:34:44 +0200 Subject: [PATCH 06/77] added missing file client value --- .../files/public/components/upload_file/upload_file.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..73211fd88ba8c 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) { const createTestBed = registerTestBed((p: Props) => ( - + )); From a129c4bb9bf6f2486a391dc91511b06118404dbe Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:35:17 +0200 Subject: [PATCH 07/77] updated file picker stories --- .../components/file_picker/file_picker.stories.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 index decea8fc3f1ba..82ee033014c4d 100644 --- 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 @@ -9,6 +9,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import { FilesContext } from '../context'; import { FilePicker } from './file_picker'; +import { FilesClient } from '../../types'; export default { title: 'components/FilePicker', @@ -16,17 +17,13 @@ export default { args: {}, decorators: [ (Story) => ( - + ), ], } as ComponentMeta; -const Template: ComponentStory = (props) => ( - - - -); +const Template: ComponentStory = (props) => ; export const Basic = Template.bind({}); From 42459a21e0839ad33234d96d0c01ee561c0c6648 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:50:56 +0200 Subject: [PATCH 08/77] expanded files context; --- .../public/components/file_picker/context.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index b2eed1a1b0317..e07b79957db78 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -5,11 +5,31 @@ * 2.0. */ -import React from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; import type { FunctionComponent } from 'react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { FilePickerState, createFilePickerState } from './file_picker_state'; -const client = new QueryClient(); -export const FilePickerContext: FunctionComponent = ({ children }) => ( - {children} +interface FilePickerContextValue { + state: FilePickerState; +} + +const FilePickerCtx = createContext( + null as unknown as FilePickerContextValue ); + +const client = new QueryClient(); +export const FilePickerContext: FunctionComponent = ({ children }) => { + const state = useMemo(createFilePickerState, []); + return ( + + {children} + + ); +}; + +export const useFilePickerContext = (): FilePickerContextValue => { + const ctx = useContext(FilePickerCtx); + if (!ctx) throw new Error('FilePickerContext not found!'); + return ctx; +}; From dfbaae51f3f88d5cb8c96de9b249552517372479 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:51:26 +0200 Subject: [PATCH 09/77] remove the size observable and also added a test for file removal and adding of duplicates --- .../file_picker/file_picker_state.test.ts | 39 ++++++++++++++++--- .../file_picker/file_picker_state.ts | 6 ++- 2 files changed, 38 insertions(+), 7 deletions(-) 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 index 6393f68c08ddd..7fd5bc371f412 100644 --- 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 @@ -6,7 +6,7 @@ */ import { TestScheduler } from 'rxjs/testing'; -import { tap } from 'rxjs'; +import { merge, tap } from 'rxjs'; import { FilePickerState } from './file_picker_state'; const getTestScheduler = () => @@ -29,10 +29,39 @@ describe('FilePickerState', () => { b: ['a'], c: ['a', 'b'], }); - expectObservable(filePickerState.size$).toBe('a-b-c-', { - a: 0, - b: 1, - c: 2, + flush(); + expect(filePickerState.isEmpty()).toBe(false); + expect(filePickerState.getFileIds()).toEqual(['a', 'b']); + }); + }); + it('updates when files are removed', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold(' --a-b---c|').pipe(tap((id) => filePickerState.addFile(id))); + const removeFiles$ = cold('------a|').pipe(tap((id) => filePickerState.removeFile(id))); + expectObservable(merge(addFiles$, removeFiles$)).toBe('--a-b-a-c|'); + expectObservable(filePickerState.fileIds$).toBe('a-b-c-d-e-', { + a: [], + b: ['a'], + c: ['a', 'b'], + d: ['b'], + e: ['b', 'c'], + }); + flush(); + expect(filePickerState.isEmpty()).toBe(false); + expect(filePickerState.getFileIds()).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.addFile(id))); + expectObservable(addFiles$).toBe('--a-b-a-a-a|'); + expectObservable(filePickerState.fileIds$).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.isEmpty()).toBe(false); 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 f712161b6a154..7294348e6c723 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 @@ -10,11 +10,9 @@ export class FilePickerState { private readonly fileSet = new Set(); private sendNext() { - this.size$.next(this.fileSet.size); this.fileIds$.next(this.getFileIds()); } public fileIds$ = new BehaviorSubject([]); - public size$ = new BehaviorSubject(0); public isEmpty() { return this.fileSet.size === 0; @@ -37,3 +35,7 @@ export class FilePickerState { return Array.from(this.fileSet); }; } + +export const createFilePickerState = (): FilePickerState => { + return new FilePickerState(); +}; From 1538c92fe2ed5e9b8d77c3523bc54e2f1d644e80 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 14:59:12 +0200 Subject: [PATCH 10/77] added error content component --- .../file_picker/components/error_content.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/error_content.tsx 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..dd0119d7f598c --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/error_content.tsx @@ -0,0 +1,26 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import { i18nTexts } from '../i18n_texts'; + +interface Props { + error: Error; +} + +export const ErrorContent: FunctionComponent = ({ error }) => { + return ( + {i18nTexts.loadingFilesErrorTitle}} + body={error.message} + /> + ); +}; From 70d749533da32bfefa9c91eab89da6acd1e924b9 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:20:15 +0200 Subject: [PATCH 11/77] refactor upload component to not take files client as prop --- .../upload_file/upload_file.stories.tsx | 132 ++++++++++++------ .../upload_file/upload_file.test.tsx | 2 +- .../components/upload_file/upload_file.tsx | 8 +- 3 files changed, 89 insertions(+), 53 deletions(-) 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 973f9f15112ef..47640d186cd29 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,7 +5,7 @@ * 2.0. */ import React from 'react'; -import { ComponentStory } from '@storybook/react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { @@ -24,17 +24,27 @@ 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, -}; + decorators: [ + (Story) => ( + ({ file: { id: 'test' } }), + upload: () => sleep(1000), + } as unknown as FilesClient + } + > + + + ), + ], +} as ComponentMeta; setFileKindsRegistry(new FileKindsRegistryImpl()); @@ -59,11 +69,7 @@ getFileKindsRegistry().register({ allowedMimeTypes: ['application/zip'], }); -const Template: ComponentStory = (props: Props) => ( - - - -); +const Template: ComponentStory = (props: Props) => ; export const Basic = Template.bind({}); @@ -73,27 +79,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) => ( + ({ 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 + } + > + + + ), +]; 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) => ( + ({ file: { id: 'test' } }), + upload: async () => { + await sleep(60000); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + + + ), +]; export const MaxSize = Template.bind({}); MaxSize.args = { @@ -118,24 +140,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) => ( + ({ file: { id: 'test' } }), + upload: async () => { + await sleep(1000); + throw new Error('Something went wrong while uploading!'); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + + + ), +]; export const ImmediateUploadAbort = Template.bind({}); +ImmediateUploadAbort.decorators = [ + (Story) => ( + ({ file: { id: 'test' } }), + upload: async () => { + await sleep(60000); + }, + delete: async () => {}, + } as unknown as FilesClient + } + > + + + ), +]; 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 73211fd88ba8c..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) { const createTestBed = registerTestBed((p: Props) => ( - + )); 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..29765b613b8ea 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 { * 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; /** * Allow users to clear a file after uploading. * @@ -82,7 +77,6 @@ export interface Props { */ export const UploadFile = ({ meta, - client, onDone, onError, allowClear, @@ -90,7 +84,7 @@ export const UploadFile = ({ immediate = false, allowRepeatedUploads = false, }: Props): ReturnType => { - const { registry } = useFilesContext(); + const { registry, client } = useFilesContext(); const ref = useRef(null); const fileKind = registry.get(kindId); const uploadState = useMemo( From 5f6134694d8fc04c71cde5ec59c9196d26a25738 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:20:47 +0200 Subject: [PATCH 12/77] updated shared file compoennts context value --- .../plugins/files/public/components/context.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/files/public/components/context.tsx b/x-pack/plugins/files/public/components/context.tsx index 3587a36205928..a18ea212beffe 100644 --- a/x-pack/plugins/files/public/components/context.tsx +++ b/x-pack/plugins/files/public/components/context.tsx @@ -11,6 +11,9 @@ import type { FilesClient } from '../types'; export interface FilesContextValue { registry: FileKindsRegistry; + /** + * A files client that will be used process uploads. + */ client: FilesClient; } @@ -23,10 +26,14 @@ export const useFilesContext = () => { } return ctx; }; -export const FilesContext: FunctionComponent<{ client: FilesClient }> = ({ - client, - children, -}) => { + +interface ContextProps { + /** + * A files client that will be used process uploads. + */ + client: FilesClient; +} +export const FilesContext: FunctionComponent = ({ client, children }) => { return ( Date: Mon, 10 Oct 2022 15:28:35 +0200 Subject: [PATCH 13/77] added test for adding multiple files at once --- .../components/file_picker/file_picker_state.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index 7fd5bc371f412..9cf5ddea18ecd 100644 --- 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 @@ -34,6 +34,16 @@ describe('FilePickerState', () => { expect(filePickerState.getFileIds()).toEqual(['a', 'b']); }); }); + it('adds files simultaneously as one update', () => { + getTestScheduler().run(({ expectObservable, cold, flush }) => { + const addFiles$ = cold('--a|').pipe(tap(() => filePickerState.addFile(['1', '2', '3']))); + expectObservable(addFiles$).toBe('--a|'); + expectObservable(filePickerState.fileIds$).toBe('a-b-', { a: [], b: ['1', '2', '3'] }); + flush(); + expect(filePickerState.isEmpty()).toBe(false); + expect(filePickerState.getFileIds()).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.addFile(id))); From 1a3b5f731327f8a69d583efe767788322847efe2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:29:23 +0200 Subject: [PATCH 14/77] allow passing in string or array of strings --- .../files/public/components/file_picker/file_picker_state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7294348e6c723..502082f565518 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 @@ -18,8 +18,8 @@ export class FilePickerState { return this.fileSet.size === 0; } - public addFile = (fileId: string): void => { - this.fileSet.add(fileId); + public addFile = (fileId: string | string[]): void => { + (Array.isArray(fileId) ? fileId : [fileId]).forEach((id) => this.fileSet.add(id)); this.sendNext(); }; From c87a23954733785ce9246943e5b5dfd74964f697 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:29:41 +0200 Subject: [PATCH 15/77] added some i18n texts --- .../public/components/file_picker/i18n_texts.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/i18n_texts.ts 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..c597cdcc5d30f --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/i18n_texts.ts @@ -0,0 +1,17 @@ +/* + * 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.fileUpload.filePicker.title', { + defaultMessage: 'Select a file', + }), + loadingFilesErrorTitle: i18n.translate('xpack.fileUpload.filePicker.error.loadingTitle', { + defaultMessage: 'Something went wrong while loading files', + }), +}; From 4c9f4389b9b513d4c3fd984098032520bd6751dd Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:45:02 +0200 Subject: [PATCH 16/77] move the creation of a file kinds registry to a common location --- .../file_picker/components/upload_files.tsx | 22 ++++++++++++++ .../file_picker/file_picker.stories.tsx | 29 +++++++++++++++---- .../files/public/components/stories_shared.ts | 15 ++++++++++ .../upload_file/upload_file.stories.tsx | 14 +++------ 4 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/upload_files.tsx create mode 100644 x-pack/plugins/files/public/components/stories_shared.ts 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..02efdc5ba8dba --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/upload_files.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 { UploadFile } from '../../upload_file'; +import { useFilePickerContext } from '../context'; + +interface Props { + kind: string; +} + +export const UploadFilesPrompt: FunctionComponent = ({ kind }) => { + const { state } = useFilePickerContext(); + return ( + state.addFile(file.map(({ id }) => id))} /> + ); +}; 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 index 82ee033014c4d..fa0de07e28d96 100644 --- 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 @@ -6,18 +6,37 @@ */ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; - +import { FilesClient, FilesClientResponses } from '../../types'; +import { fileKindsRegistry } from '../stories_shared'; import { FilesContext } from '../context'; -import { FilePicker } from './file_picker'; -import { FilesClient } from '../../types'; +import { FilePicker, Props as FilePickerProps } from './file_picker'; + +const kind = 'filepicker'; +fileKindsRegistry.register({ + id: kind, + http: {}, + allowedMimeTypes: ['*'], +}); + +const defaultProps: FilePickerProps = { + kind, +}; export default { title: 'components/FilePicker', component: FilePicker, - args: {}, + args: defaultProps, decorators: [ (Story) => ( - + => ({ + files: [], + }), + } as unknown as FilesClient + } + > ), 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..d64777c1ff597 --- /dev/null +++ b/x-pack/plugins/files/public/components/stories_shared.ts @@ -0,0 +1,15 @@ +/* + * 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 { + setFileKindsRegistry, + getFileKindsRegistry, + FileKindsRegistryImpl, +} from '../../common/file_kinds_registry'; + +setFileKindsRegistry(new FileKindsRegistryImpl()); +export const fileKindsRegistry = getFileKindsRegistry(); 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 47640d186cd29..1f5c28df2ce63 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 @@ -8,11 +8,7 @@ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { - FileKindsRegistryImpl, - setFileKindsRegistry, - getFileKindsRegistry, -} from '../../../common/file_kinds_registry'; +import { fileKindsRegistry } from '../stories_shared'; import { FilesClient } from '../../types'; import { FilesContext } from '../context'; import { UploadFile, Props } from './upload_file'; @@ -46,16 +42,14 @@ export default { ], } as ComponentMeta; -setFileKindsRegistry(new FileKindsRegistryImpl()); - -getFileKindsRegistry().register({ +fileKindsRegistry.register({ id: kind, http: {}, allowedMimeTypes: ['*'], }); const miniFile = 'miniFile'; -getFileKindsRegistry().register({ +fileKindsRegistry.register({ id: miniFile, http: {}, maxSizeBytes: 1, @@ -63,7 +57,7 @@ getFileKindsRegistry().register({ }); const zipOnly = 'zipOnly'; -getFileKindsRegistry().register({ +fileKindsRegistry.register({ id: zipOnly, http: {}, allowedMimeTypes: ['application/zip'], From f080aecffbec0c2c052ee777a9b36bb3122004be Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 10 Oct 2022 15:51:35 +0200 Subject: [PATCH 17/77] set file kinds only once --- .../public/components/file_picker/file_picker.stories.tsx | 4 ++-- x-pack/plugins/files/public/components/stories_shared.ts | 8 +++++++- .../public/components/upload_file/upload_file.stories.tsx | 8 ++++---- 3 files changed, 13 insertions(+), 7 deletions(-) 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 index fa0de07e28d96..03ab6ae09345b 100644 --- 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 @@ -7,12 +7,12 @@ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import { FilesClient, FilesClientResponses } from '../../types'; -import { fileKindsRegistry } from '../stories_shared'; +import { register } from '../stories_shared'; import { FilesContext } from '../context'; import { FilePicker, Props as FilePickerProps } from './file_picker'; const kind = 'filepicker'; -fileKindsRegistry.register({ +register({ id: kind, http: {}, allowedMimeTypes: ['*'], diff --git a/x-pack/plugins/files/public/components/stories_shared.ts b/x-pack/plugins/files/public/components/stories_shared.ts index d64777c1ff597..a82ec3295b1d0 100644 --- a/x-pack/plugins/files/public/components/stories_shared.ts +++ b/x-pack/plugins/files/public/components/stories_shared.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { FileKind } from '../../common'; import { setFileKindsRegistry, getFileKindsRegistry, @@ -12,4 +13,9 @@ import { } from '../../common/file_kinds_registry'; setFileKindsRegistry(new FileKindsRegistryImpl()); -export const fileKindsRegistry = getFileKindsRegistry(); +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.stories.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.stories.tsx index 1f5c28df2ce63..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 @@ -8,7 +8,7 @@ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { fileKindsRegistry } from '../stories_shared'; +import { register } from '../stories_shared'; import { FilesClient } from '../../types'; import { FilesContext } from '../context'; import { UploadFile, Props } from './upload_file'; @@ -42,14 +42,14 @@ export default { ], } as ComponentMeta; -fileKindsRegistry.register({ +register({ id: kind, http: {}, allowedMimeTypes: ['*'], }); const miniFile = 'miniFile'; -fileKindsRegistry.register({ +register({ id: miniFile, http: {}, maxSizeBytes: 1, @@ -57,7 +57,7 @@ fileKindsRegistry.register({ }); const zipOnly = 'zipOnly'; -fileKindsRegistry.register({ +register({ id: zipOnly, http: {}, allowedMimeTypes: ['application/zip'], From 98fa226587e3ff70473381be33db76ee09d84e63 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 11 Oct 2022 11:35:06 +0200 Subject: [PATCH 18/77] a bunch of stuff: added title component, using grid, removed responsive on upload controls --- .../file_picker/components/title.tsx | 16 ++++ .../file_picker/components/upload_files.tsx | 7 +- .../file_picker/file_picker.stories.tsx | 2 +- .../components/file_picker/file_picker.tsx | 90 +++++++++++++++++-- .../upload_file/upload_file.component.tsx | 7 +- .../components/upload_file/upload_file.tsx | 6 ++ 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/title.tsx 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 index 02efdc5ba8dba..bdf6b5f556d18 100644 --- 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 @@ -17,6 +17,11 @@ interface Props { export const UploadFilesPrompt: FunctionComponent = ({ kind }) => { const { state } = useFilePickerContext(); return ( - state.addFile(file.map(({ id }) => id))} /> + state.addFile(file.map(({ id }) => id))} + /> ); }; 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 index 03ab6ae09345b..89cbf5fc1ed0b 100644 --- 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 @@ -45,4 +45,4 @@ export default { const Template: ComponentStory = (props) => ; -export const Basic = Template.bind({}); +export const Empty = Template.bind({}); 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 5965ad9b7a23d..f82576eb0d580 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 @@ -4,17 +4,97 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useQuery } from '@tanstack/react-query'; -import type { FunctionComponent } from 'react'; import React from 'react'; -import { UploadFile } from '../upload_file'; +import type { FunctionComponent } from 'react'; +import { EuiButton, EuiLoadingSpinner, useEuiTheme } from '@elastic/eui'; +import { useQuery } from '@tanstack/react-query'; +import { css } from '@emotion/react'; +import { FilePickerContext } from './context'; import { useFilesContext } from '../context'; +import { useFilePickerContext } from './context'; +import { useBehaviorSubject } from '../use_behavior_subject'; + +import { Title } from './components/title'; +import { ErrorContent } from './components/error_content'; +import { UploadFilesPrompt } from './components/upload_files'; export interface Props { + /** + * The file kind that was passed to the registry. + */ kind: Kind; + /** + * The number of results to show per page. + */ + perPage?: number; } -export const FilePicker: FunctionComponent = ({}) => { - return

OK

; +const Component: FunctionComponent = ({ kind, perPage }) => { + const { client } = useFilesContext(); + const { state } = useFilePickerContext(); + const selectedFiles = useBehaviorSubject(state.fileIds$); + const { status, error, data } = useQuery({ + queryFn: () => client.list({ kind, perPage }), + retry: false, + }); + const { euiTheme } = useEuiTheme(); + + const hasFilesSelected = Boolean(selectedFiles.length); + + return ( +
+
+ + </div> + <div + css={css` + grid-area: content; + place-self: center stretch; + `} + > + {status === 'loading' ? ( + <EuiLoadingSpinner size="xl" /> + ) : status === 'error' ? ( + <ErrorContent error={error as Error} /> + ) : data.files.length === 0 ? ( + <UploadFilesPrompt kind={kind} /> + ) : ( + // TODO actually make some content here + 'OK' + )} + </div> + <div + css={css` + grid-area: footer; + place-self: center end; + `} + > + <EuiButton disabled={!hasFilesSelected}>Select file(s)</EuiButton> + </div> + </div> + ); }; + +export const FilePicker: FunctionComponent<Props> = (props) => ( + <FilePickerContext> + <Component {...props} /> + </FilePickerContext> +); 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..a1ff4fa318076 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,7 @@ import { useUploadState } from './context'; export interface Props { meta?: unknown; accept?: string; + fullWidth?: boolean; immediate?: boolean; allowClear?: boolean; initialFilePromptText?: string; @@ -33,7 +34,7 @@ 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, initialFilePromptText, fullWidth }, ref) => { const uploadState = useUploadState(); const uploading = useBehaviorSubject(uploadState.uploading$); const error = useBehaviorSubject(uploadState.error$); @@ -48,10 +49,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} @@ -75,6 +77,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.tsx b/x-pack/plugins/files/public/components/upload_file/upload_file.tsx index 29765b613b8ea..b7350f58ca5c9 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 @@ -51,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. @@ -79,6 +83,7 @@ export const UploadFile = <Kind extends string = string>({ meta, onDone, onError, + fullWidth, allowClear, kind: kindId, immediate = false, @@ -121,6 +126,7 @@ export const UploadFile = <Kind extends string = string>({ meta={meta} immediate={immediate} allowClear={allowClear} + fullWidth={fullWidth} /> </context.Provider> ); From 83918532177155352637ffd465b12c7264a59790 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 11:36:10 +0200 Subject: [PATCH 19/77] refactor layour components to own component using grid, added new i18n texts for empty state prompt --- .../file_picker/components/layout/grid.tsx | 61 ++++++++++++ .../file_picker/components/layout/index.ts | 8 ++ .../file_picker/components/upload_files.tsx | 2 + .../file_picker/file_picker.stories.tsx | 1 + .../components/file_picker/file_picker.tsx | 92 ++++++++++--------- .../file_picker/file_picker_state.ts | 4 + .../components/file_picker/i18n_texts.ts | 3 + .../components/upload_file/upload_file.tsx | 6 ++ 8 files changed, 134 insertions(+), 43 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/components/layout/index.ts diff --git a/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx b/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx new file mode 100644 index 0000000000000..d49402f058990 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx @@ -0,0 +1,61 @@ +/* + * 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 { css } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +// CP = CommonProps +interface CP { + className?: string; +} + +export const Grid: FunctionComponent<CP> = ({ children }) => { + const { euiTheme } = useEuiTheme(); + return ( + <div + css={css` + display: grid; + place-items: center; + grid-template-columns: ${euiTheme.size.m} 1fr 2fr 1fr ${euiTheme.size.m}; + grid-template-rows: + ${euiTheme.size.xl} + ${euiTheme.size.m} + 3fr + 1fr; + grid-template-areas: + '. title title . .' + '. . . . .' + '. content content content .' + '. footer footer footer .'; + `} + > + {children} + </div> + ); +}; + +const Area = + (area: 'title' | 'content' | 'footer' | 'content / content / footer'): FunctionComponent<CP> => + ({ children, className }) => { + return ( + <div + css={css` + grid-area: ${area}; + `} + className={className} + > + {children} + </div> + ); + }; + +export const Header = Area('title'); +export const Content = Area('content'); +export const ContentAndFooter = Area('content / content / footer'); // span the content and footer areas +export const Footer = Area('footer'); diff --git a/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts b/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts new file mode 100644 index 0000000000000..ab408cc0e6701 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { Grid, Header, Content, Footer, ContentAndFooter } from './grid'; 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 index bdf6b5f556d18..15bb177216ad9 100644 --- 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 @@ -9,6 +9,7 @@ import React from 'react'; import type { FunctionComponent } from 'react'; import { UploadFile } from '../../upload_file'; import { useFilePickerContext } from '../context'; +import { i18nTexts } from '../i18n_texts'; interface Props { kind: string; @@ -22,6 +23,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { immediate fullWidth onDone={(file) => state.addFile(file.map(({ id }) => id))} + initialPromptText={i18nTexts.emptyStatePrompt} /> ); }; 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 index 89cbf5fc1ed0b..8306e62e8d763 100644 --- 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 @@ -31,6 +31,7 @@ export default { <FilesContext client={ { + create: () => Promise.reject(new Error('not so fast buster!')), list: async (): Promise<FilesClientResponses['list']> => ({ files: [], }), 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 f82576eb0d580..371d2ebbb3d0a 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 @@ -6,7 +6,7 @@ */ import React from 'react'; import type { FunctionComponent } from 'react'; -import { EuiButton, EuiLoadingSpinner, useEuiTheme } from '@elastic/eui'; +import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import { useQuery } from '@tanstack/react-query'; import { css } from '@emotion/react'; import { FilePickerContext } from './context'; @@ -18,6 +18,7 @@ import { useBehaviorSubject } from '../use_behavior_subject'; import { Title } from './components/title'; import { ErrorContent } from './components/error_content'; import { UploadFilesPrompt } from './components/upload_files'; +import * as layout from './components/layout'; export interface Props<Kind extends string = string> { /** @@ -38,58 +39,63 @@ const Component: FunctionComponent<Props> = ({ kind, perPage }) => { queryFn: () => client.list({ kind, perPage }), retry: false, }); - const { euiTheme } = useEuiTheme(); - - const hasFilesSelected = Boolean(selectedFiles.length); return ( - <div - css={css` - display: grid; - place-items: center; - grid-template-columns: ${euiTheme.size.m} 1fr 2fr 1fr ${euiTheme.size.m}; - grid-template-rows: 1fr ${euiTheme.size.m} 3fr 1fr; - grid-template-areas: - '. title title . .' - '. . . . .' - '. content content content .' - '. footer footer footer .'; - `} - > - <div + <layout.Grid> + <layout.Header css={css` - grid-area: title; place-self: center start; `} > <Title /> - </div> - <div - css={css` - grid-area: content; - place-self: center stretch; - `} - > - {status === 'loading' ? ( + </layout.Header> + {status === 'loading' ? ( + <layout.ContentAndFooter + css={css` + place-self: center stretch; + `} + > <EuiLoadingSpinner size="xl" /> - ) : status === 'error' ? ( + </layout.ContentAndFooter> + ) : status === 'error' ? ( + <layout.ContentAndFooter + css={css` + place-self: center stretch; + `} + > <ErrorContent error={error as Error} /> - ) : data.files.length === 0 ? ( + </layout.ContentAndFooter> + ) : data.files.length === 0 ? ( + <layout.ContentAndFooter + css={css` + place-self: center stretch; + `} + > <UploadFilesPrompt kind={kind} /> - ) : ( - // TODO actually make some content here - 'OK' - )} - </div> - <div - css={css` - grid-area: footer; - place-self: center end; - `} - > - <EuiButton disabled={!hasFilesSelected}>Select file(s)</EuiButton> - </div> - </div> + </layout.ContentAndFooter> + ) : ( + <> + <layout.Content + css={css` + grid-area: content; + `} + > + { + // TODO actually make some content here + 'OK' + } + </layout.Content> + <layout.Footer + css={css` + grid-area: footer; + place-self: center end; + `} + > + <EuiButton disabled={!state.hasFilesSelected()}>Select file(s)</EuiButton> + </layout.Footer> + </> + )} + </layout.Grid> ); }; 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 502082f565518..3caa154afa73e 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 @@ -18,6 +18,10 @@ export class FilePickerState { return this.fileSet.size === 0; } + public hasFilesSelected = (): boolean => { + return this.fileSet.size > 0; + }; + public addFile = (fileId: string | string[]): void => { (Array.isArray(fileId) ? fileId : [fileId]).forEach((id) => this.fileSet.add(id)); this.sendNext(); 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 c597cdcc5d30f..dfaf63be6cc60 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 @@ -14,4 +14,7 @@ export const i18nTexts = { loadingFilesErrorTitle: i18n.translate('xpack.fileUpload.filePicker.error.loadingTitle', { defaultMessage: 'Something went wrong while loading files', }), + emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { + defaultMessage: 'Upload a file to get started', + }), }; 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 b7350f58ca5c9..4f3e148d96fd3 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 @@ -62,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 */ @@ -86,6 +90,7 @@ export const UploadFile = <Kind extends string = string>({ fullWidth, allowClear, kind: kindId, + initialPromptText, immediate = false, allowRepeatedUploads = false, }: Props<Kind>): ReturnType<FunctionComponent> => { @@ -127,6 +132,7 @@ export const UploadFile = <Kind extends string = string>({ immediate={immediate} allowClear={allowClear} fullWidth={fullWidth} + initialFilePromptText={initialPromptText} /> </context.Provider> ); From 569de31bbf68d1d58d0b2865631b9663d9c6e71b Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 11:52:56 +0200 Subject: [PATCH 20/77] minor copy tweak --- .../plugins/files/public/components/file_picker/i18n_texts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dfaf63be6cc60..f398aefe59efc 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 @@ -15,6 +15,6 @@ export const i18nTexts = { defaultMessage: 'Something went wrong while loading files', }), emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { - defaultMessage: 'Upload a file to get started', + defaultMessage: 'No files found, upload your first file.', }), }; From 3a49db2fdb8f72e5fddbe9ef0d41223cb8c55bc5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 11:58:20 +0200 Subject: [PATCH 21/77] added basic story with some files --- .../file_picker/file_picker.stories.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 index 8306e62e8d763..8921e8fe3b3c6 100644 --- 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 @@ -6,6 +6,7 @@ */ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { FileJSON } from '../../../common'; import { FilesClient, FilesClientResponses } from '../../types'; import { register } from '../stories_shared'; import { FilesContext } from '../context'; @@ -47,3 +48,36 @@ export default { const Template: ComponentStory<typeof FilePicker> = (props) => <FilePicker {...props} />; export const Empty = Template.bind({}); + +const d = new Date(); +function createFileJSON(): FileJSON { + return { + alt: '', + created: d.toISOString(), + updated: d.toISOString(), + extension: 'png', + fileKind: kind, + id: '1', + meta: {}, + mimeType: 'image/png', + name: 'my file', + size: 1, + status: 'READY', + }; +} +export const Basic = Template.bind({}); +Basic.decorators = [ + (Story) => ( + <FilesContext + client={ + { + list: async (): Promise<FilesClientResponses['list']> => ({ + files: [createFileJSON(), createFileJSON(), createFileJSON(), createFileJSON()], + }), + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; From 6844647f582867cd030548abced46721846a1c43 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 14:34:16 +0200 Subject: [PATCH 22/77] added file grid and refactored picker to only exist in modal for now --- .../file_picker/components/file_card.tsx | 69 ++++++++++++++++ .../file_picker/components/file_grid.tsx | 34 ++++++++ .../file_picker/components/layout/grid.tsx | 61 -------------- .../file_picker/components/layout/index.ts | 8 -- .../public/components/file_picker/context.tsx | 13 ++- .../file_picker/file_picker.stories.tsx | 20 ++++- .../components/file_picker/file_picker.tsx | 80 ++++++++++--------- .../public/components/util/image_metadata.ts | 4 +- 8 files changed, 174 insertions(+), 115 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/file_card.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/components/file_grid.tsx delete mode 100644 x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx delete mode 100644 x-pack/plugins/files/public/components/file_picker/components/layout/index.ts 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..39f55d1262e86 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/file_card.tsx @@ -0,0 +1,69 @@ +/* + * 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 { 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 { useFilesContext } from '../../context'; +import { useFilePickerContext } from '../context'; + +interface Props { + file: FileJSON; +} + +export const FileCard: FunctionComponent<Props> = ({ file }) => { + const { client } = useFilesContext(); + const { kind } = useFilePickerContext(); + const { euiTheme } = useEuiTheme(); + const displayImage = isImage({ type: file.mimeType }); + return ( + <EuiCard + titleSize="xs" + title={file.name} + image={ + <div + css={css` + display: grid; + place-items: center; + min-height: ${euiTheme.size.xxxxl}; + `} + > + {displayImage ? ( + <Image + size="m" + alt={file.alt ?? ''} + src={client.getDownloadHref({ id: file.id, fileKind: kind })} + /> + ) : ( + <EuiIcon type="filebeatApp" size="xl" /> + )} + </div> + } + description={ + <> + {displayImage ? ( + (file as FileJSON<FileImageMetadata>).meta?.height != null ? ( + <EuiText color="subdued" size="xs"> + {(file as FileJSON<FileImageMetadata>).meta?.width}px by{' '} + {(file as FileJSON<FileImageMetadata>).meta?.height}px + </EuiText> + ) : null + ) : null} + <EuiText color="subdued" size="xs"> + <p>{numeral(file.size).format('0[.]0 b')}</p> + </EuiText> + </> + } + 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..9dc673c2d23f5 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/file_grid.tsx @@ -0,0 +1,34 @@ +/* + * 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 } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { FileJSON } from '../../../../common'; +import { FileCard } from './file_card'; + +interface Props { + files: FileJSON[]; +} + +export const FileGrid: FunctionComponent<Props> = ({ files }) => { + const { euiTheme } = useEuiTheme(); + return ( + <div + css={css` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(calc(${euiTheme.size.xxxxl} * 3.2), 1fr)); + gap: ${euiTheme.size.m}; + `} + > + {files.map((file) => ( + <FileCard file={file} /> + ))} + </div> + ); +}; diff --git a/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx b/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx deleted file mode 100644 index d49402f058990..0000000000000 --- a/x-pack/plugins/files/public/components/file_picker/components/layout/grid.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 { css } from '@emotion/react'; -import { useEuiTheme } from '@elastic/eui'; - -// CP = CommonProps -interface CP { - className?: string; -} - -export const Grid: FunctionComponent<CP> = ({ children }) => { - const { euiTheme } = useEuiTheme(); - return ( - <div - css={css` - display: grid; - place-items: center; - grid-template-columns: ${euiTheme.size.m} 1fr 2fr 1fr ${euiTheme.size.m}; - grid-template-rows: - ${euiTheme.size.xl} - ${euiTheme.size.m} - 3fr - 1fr; - grid-template-areas: - '. title title . .' - '. . . . .' - '. content content content .' - '. footer footer footer .'; - `} - > - {children} - </div> - ); -}; - -const Area = - (area: 'title' | 'content' | 'footer' | 'content / content / footer'): FunctionComponent<CP> => - ({ children, className }) => { - return ( - <div - css={css` - grid-area: ${area}; - `} - className={className} - > - {children} - </div> - ); - }; - -export const Header = Area('title'); -export const Content = Area('content'); -export const ContentAndFooter = Area('content / content / footer'); // span the content and footer areas -export const Footer = Area('footer'); diff --git a/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts b/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts deleted file mode 100644 index ab408cc0e6701..0000000000000 --- a/x-pack/plugins/files/public/components/file_picker/components/layout/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * 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. - */ - -export { Grid, Header, Content, Footer, ContentAndFooter } from './grid'; diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index e07b79957db78..917f2ded2bade 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -12,18 +12,25 @@ import { FilePickerState, createFilePickerState } from './file_picker_state'; interface FilePickerContextValue { state: FilePickerState; + kind: string; } const FilePickerCtx = createContext<FilePickerContextValue>( null as unknown as FilePickerContextValue ); -const client = new QueryClient(); -export const FilePickerContext: FunctionComponent = ({ children }) => { +interface FilePickerContextProps { + kind: string; +} +export const FilePickerContext: FunctionComponent<FilePickerContextProps> = ({ + kind, + children, +}) => { + const client = useMemo(() => new QueryClient(), []); const state = useMemo(createFilePickerState, []); return ( <QueryClientProvider client={client}> - <FilePickerCtx.Provider value={{ state }}>{children}</FilePickerCtx.Provider> + <FilePickerCtx.Provider value={{ state, kind }}>{children}</FilePickerCtx.Provider> </QueryClientProvider> ); }; 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 index 8921e8fe3b3c6..2c4d8617026c8 100644 --- 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 @@ -9,6 +9,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; 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'; @@ -21,6 +22,8 @@ register({ const defaultProps: FilePickerProps = { kind, + onDone: () => {}, + onClose: () => {}, }; export default { @@ -58,7 +61,10 @@ function createFileJSON(): FileJSON { extension: 'png', fileKind: kind, id: '1', - meta: {}, + meta: { + width: 1000, + height: 1000, + }, mimeType: 'image/png', name: 'my file', size: 1, @@ -71,8 +77,18 @@ Basic.decorators = [ <FilesContext client={ { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, list: async (): Promise<FilesClientResponses['list']> => ({ - files: [createFileJSON(), createFileJSON(), createFileJSON(), createFileJSON()], + files: [ + createFileJSON(), + createFileJSON(), + createFileJSON(), + createFileJSON(), + createFileJSON(), + createFileJSON(), + createFileJSON(), + createFileJSON(), + ], }), } as unknown as FilesClient } 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 371d2ebbb3d0a..86b36ad127fda 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 @@ -6,7 +6,14 @@ */ import React from 'react'; import type { FunctionComponent } from 'react'; -import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiButton, + EuiLoadingSpinner, + EuiModal, + EuiModalHeader, + EuiModalBody, + EuiModalFooter, +} from '@elastic/eui'; import { useQuery } from '@tanstack/react-query'; import { css } from '@emotion/react'; import { FilePickerContext } from './context'; @@ -19,21 +26,30 @@ import { Title } from './components/title'; import { ErrorContent } from './components/error_content'; import { UploadFilesPrompt } from './components/upload_files'; import * as layout from './components/layout'; +import { FileGrid } from './components/file_grid'; export interface Props<Kind extends string = string> { /** * 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. */ perPage?: number; } -const Component: FunctionComponent<Props> = ({ kind, perPage }) => { +const Component: FunctionComponent<Props> = ({ perPage, onClose }) => { const { client } = useFilesContext(); - const { state } = useFilePickerContext(); + const { state, kind } = useFilePickerContext(); const selectedFiles = useBehaviorSubject(state.fileIds$); const { status, error, data } = useQuery({ queryFn: () => client.list({ kind, perPage }), @@ -41,66 +57,52 @@ const Component: FunctionComponent<Props> = ({ kind, perPage }) => { }); return ( - <layout.Grid> - <layout.Header - css={css` - place-self: center start; - `} - > + <EuiModal + css={css` + min-width: 75vw; + min-height: 20vw; + `} + onClose={onClose} + > + <EuiModalHeader> <Title /> - </layout.Header> + </EuiModalHeader> {status === 'loading' ? ( - <layout.ContentAndFooter + <EuiModalBody css={css` place-self: center stretch; `} > <EuiLoadingSpinner size="xl" /> - </layout.ContentAndFooter> + </EuiModalBody> ) : status === 'error' ? ( - <layout.ContentAndFooter + <EuiModalBody css={css` place-self: center stretch; `} > <ErrorContent error={error as Error} /> - </layout.ContentAndFooter> + </EuiModalBody> ) : data.files.length === 0 ? ( - <layout.ContentAndFooter - css={css` - place-self: center stretch; - `} - > + <EuiModalBody> <UploadFilesPrompt kind={kind} /> - </layout.ContentAndFooter> + </EuiModalBody> ) : ( <> - <layout.Content - css={css` - grid-area: content; - `} - > - { - // TODO actually make some content here - 'OK' - } - </layout.Content> - <layout.Footer - css={css` - grid-area: footer; - place-self: center end; - `} - > + <EuiModalBody> + <FileGrid files={data.files} /> + </EuiModalBody> + <EuiModalFooter> <EuiButton disabled={!state.hasFilesSelected()}>Select file(s)</EuiButton> - </layout.Footer> + </EuiModalFooter> </> )} - </layout.Grid> + </EuiModal> ); }; export const FilePicker: FunctionComponent<Props> = (props) => ( - <FilePickerContext> + <FilePickerContext kind={props.kind}> <Component {...props} /> </FilePickerContext> ); 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 = { From f2e71a5fc2fdaeff2b9de22e6caa5a103c5caa99 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 17:29:48 +0200 Subject: [PATCH 23/77] get the css grid algo that we want: auto-fill not auto-fit! --- .../file_picker/components/file_card.tsx | 34 +++++++++++-------- .../file_picker/components/file_grid.tsx | 2 +- .../file_picker/components/upload_files.tsx | 2 +- .../file_picker/file_picker.stories.tsx | 8 +++-- .../components/file_picker/file_picker.tsx | 20 +++++------ .../components/file_picker/i18n_texts.ts | 8 +++++ 6 files changed, 44 insertions(+), 30 deletions(-) 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 39f55d1262e86..202263a342251 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 @@ -10,7 +10,7 @@ import type { FunctionComponent } from 'react'; import numeral from '@elastic/numeral'; import { EuiCard, EuiText, EuiIcon, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { FileImageMetadata, FileJSON } from '../../../../common'; +import { FileJSON } from '../../../../common'; import { Image } from '../../image'; import { isImage } from '../../util'; import { useFilesContext } from '../../context'; @@ -22,24 +22,31 @@ interface Props { export const FileCard: FunctionComponent<Props> = ({ file }) => { const { client } = useFilesContext(); - const { kind } = useFilePickerContext(); + const { kind, state } = useFilePickerContext(); const { euiTheme } = useEuiTheme(); const displayImage = isImage({ type: file.mimeType }); + const isSelected = state.hasFileId(file.id); return ( <EuiCard - titleSize="xs" - title={file.name} + title="" + css={css` + place-self: center; + `} + paddingSize="s" + selectable={{ + isSelected, + onClick: () => (isSelected ? state.removeFile(file.id) : state.addFile(file.id)), + }} image={ <div css={css` display: grid; place-items: center; - min-height: ${euiTheme.size.xxxxl}; + height: calc(${euiTheme.size.xxxxl} * 2); `} > {displayImage ? ( <Image - size="m" alt={file.alt ?? ''} src={client.getDownloadHref({ id: file.id, fileKind: kind })} /> @@ -50,16 +57,13 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { } description={ <> - {displayImage ? ( - (file as FileJSON<FileImageMetadata>).meta?.height != null ? ( - <EuiText color="subdued" size="xs"> - {(file as FileJSON<FileImageMetadata>).meta?.width}px by{' '} - {(file as FileJSON<FileImageMetadata>).meta?.height}px - </EuiText> - ) : null - ) : null} + <EuiText size="xs"> + <strong> + {file.name}.{file.extension} + </strong> + </EuiText> <EuiText color="subdued" size="xs"> - <p>{numeral(file.size).format('0[.]0 b')}</p> + {numeral(file.size).format('0[.]0 b')} </EuiText> </> } 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 index 9dc673c2d23f5..0d17a30934e6c 100644 --- 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 @@ -22,7 +22,7 @@ export const FileGrid: FunctionComponent<Props> = ({ files }) => { <div css={css` display: grid; - grid-template-columns: repeat(auto-fit, minmax(calc(${euiTheme.size.xxxxl} * 3.2), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(calc(${euiTheme.size.xxxxl} * 2.5), 1fr)); gap: ${euiTheme.size.m}; `} > 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 index 15bb177216ad9..e0c2bb3ad7d60 100644 --- 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 @@ -21,7 +21,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { <UploadFile kind={kind} immediate - fullWidth + // fullWidth onDone={(file) => state.addFile(file.map(({ id }) => id))} initialPromptText={i18nTexts.emptyStatePrompt} /> 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 index 2c4d8617026c8..521c5d31f8dcf 100644 --- 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 @@ -6,6 +6,7 @@ */ 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'; @@ -22,8 +23,8 @@ register({ const defaultProps: FilePickerProps = { kind, - onDone: () => {}, - onClose: () => {}, + onDone: action('done!'), + onClose: action('close!'), }; export default { @@ -53,6 +54,7 @@ const Template: ComponentStory<typeof FilePicker> = (props) => <FilePicker {...p export const Empty = Template.bind({}); const d = new Date(); +let id = 0; function createFileJSON(): FileJSON { return { alt: '', @@ -60,7 +62,7 @@ function createFileJSON(): FileJSON { updated: d.toISOString(), extension: 'png', fileKind: kind, - id: '1', + id: String(++id), meta: { width: 1000, height: 1000, 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 86b36ad127fda..ecff6c06fd1de 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 @@ -25,8 +25,10 @@ import { useBehaviorSubject } from '../use_behavior_subject'; import { Title } from './components/title'; import { ErrorContent } from './components/error_content'; import { UploadFilesPrompt } from './components/upload_files'; -import * as layout from './components/layout'; import { FileGrid } from './components/file_grid'; +import { i18nTexts } from './i18n_texts'; + +import './file_picker.scss'; export interface Props<Kind extends string = string> { /** @@ -47,7 +49,7 @@ export interface Props<Kind extends string = string> { perPage?: number; } -const Component: FunctionComponent<Props> = ({ perPage, onClose }) => { +const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { const { client } = useFilesContext(); const { state, kind } = useFilePickerContext(); const selectedFiles = useBehaviorSubject(state.fileIds$); @@ -57,13 +59,7 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose }) => { }); return ( - <EuiModal - css={css` - min-width: 75vw; - min-height: 20vw; - `} - onClose={onClose} - > + <EuiModal className="filesFilePicker" onClose={onClose}> <EuiModalHeader> <Title /> </EuiModalHeader> @@ -93,7 +89,11 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose }) => { <FileGrid files={data.files} /> </EuiModalBody> <EuiModalFooter> - <EuiButton disabled={!state.hasFilesSelected()}>Select file(s)</EuiButton> + <EuiButton disabled={!state.hasFilesSelected()} onClick={() => onDone(selectedFiles)}> + {selectedFiles.length > 1 + ? i18nTexts.selectFilesLabel(selectedFiles.length) + : i18nTexts.selectFileLabel} + </EuiButton> </EuiModalFooter> </> )} 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 f398aefe59efc..f6e3d7594be7e 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 @@ -17,4 +17,12 @@ export const i18nTexts = { emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { defaultMessage: 'No files found, upload your first file.', }), + selectFileLabel: i18n.translate('xpack.fileUpload.filePicker.selectFileButtonLable', { + defaultMessage: 'Select file', + }), + selectFilesLabel: (nrOfFiles: number) => + i18n.translate('xpack.fileUpload.filePicker.selectFilesButtonLable', { + defaultMessage: 'Select {nrOfFiles} files', + values: { nrOfFiles }, + }), }; From 69ba34b1a3e050730f8020022a046045be2add50 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 17:30:13 +0200 Subject: [PATCH 24/77] override styling for content area of file --- .../files/public/components/file_picker/file_picker.scss | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.scss 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..1d7bd136a7e69 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.scss @@ -0,0 +1,5 @@ +.filesFilePicker { + .euiCard__content { + margin: 0; + } +} \ No newline at end of file From abaf1f2702287c49bfb93333c024e54a5804a964 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 17:31:12 +0200 Subject: [PATCH 25/77] split stories of files --- .../file_picker/file_picker.stories.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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 index 521c5d31f8dcf..2623bd415580c 100644 --- 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 @@ -73,8 +73,26 @@ function createFileJSON(): FileJSON { status: 'READY', }; } -export const Basic = Template.bind({}); -Basic.decorators = [ +export const BasicOne = Template.bind({}); +BasicOne.decorators = [ + (Story) => ( + <FilesContext + client={ + { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, + list: async (): Promise<FilesClientResponses['list']> => ({ + files: [createFileJSON()], + }), + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ), +]; + +export const BasicMany = Template.bind({}); +BasicMany.decorators = [ (Story) => ( <FilesContext client={ @@ -89,7 +107,6 @@ Basic.decorators = [ createFileJSON(), createFileJSON(), createFileJSON(), - createFileJSON(), ], }), } as unknown as FilesClient From 51ba0993b6e672a8d3348809c607d4b773124259 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 17:31:41 +0200 Subject: [PATCH 26/77] delete commented out code --- .../public/components/file_picker/components/upload_files.tsx | 1 - 1 file changed, 1 deletion(-) 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 index e0c2bb3ad7d60..dacee46d84b09 100644 --- 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 @@ -21,7 +21,6 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { <UploadFile kind={kind} immediate - // fullWidth onDone={(file) => state.addFile(file.map(({ id }) => id))} initialPromptText={i18nTexts.emptyStatePrompt} /> From 9bd9f758a8c1c152379e66cb74ae68c3e06f45c0 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 18:37:23 +0200 Subject: [PATCH 27/77] give the modal a fixed width --- .../components/file_picker/components/file_card.tsx | 6 +++++- .../files/public/components/file_picker/file_picker.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 202263a342251..32b5092d97de9 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 @@ -26,6 +26,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { const { euiTheme } = useEuiTheme(); const displayImage = isImage({ type: file.mimeType }); const isSelected = state.hasFileId(file.id); + const imageHeight = `calc(${euiTheme.size.xxxl} * 2)`; return ( <EuiCard title="" @@ -42,12 +43,15 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { css={css` display: grid; place-items: center; - height: calc(${euiTheme.size.xxxxl} * 2); + height: ${imageHeight}; `} > {displayImage ? ( <Image alt={file.alt ?? ''} + css={css` + max-height: ${imageHeight}; + `} src={client.getDownloadHref({ id: file.id, fileKind: kind })} /> ) : ( 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 ecff6c06fd1de..642f92a58631e 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 @@ -59,7 +59,14 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { }); return ( - <EuiModal className="filesFilePicker" onClose={onClose}> + <EuiModal + className="filesFilePicker" + css={css` + width: 75vw; + `} + maxWidth="75vw" + onClose={onClose} + > <EuiModalHeader> <Title /> </EuiModalHeader> From e56b10ae3f0cbd9d191b2d217b20db1ae924856e Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 11 Oct 2022 18:41:56 +0200 Subject: [PATCH 28/77] fix upload file state, where we do not want a fixed width modal --- .../public/components/file_picker/file_picker.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 642f92a58631e..3a1305923c7d5 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 @@ -57,16 +57,14 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { queryFn: () => client.list({ kind, perPage }), retry: false, }); + const fixedWidth = Boolean(data?.files.length) + ? css` + width: 75vw; + ` + : undefined; return ( - <EuiModal - className="filesFilePicker" - css={css` - width: 75vw; - `} - maxWidth="75vw" - onClose={onClose} - > + <EuiModal className="filesFilePicker" css={fixedWidth} maxWidth="75vw" onClose={onClose}> <EuiModalHeader> <Title /> </EuiModalHeader> From 92ca4128a57013a71e3b3c959d6f931553459f3b Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 11:30:41 +0200 Subject: [PATCH 29/77] moved styles down to card, and combined margin removal rules --- .../public/components/file_picker/components/file_card.scss | 5 +++++ .../public/components/file_picker/components/file_card.tsx | 4 +++- .../files/public/components/file_picker/file_picker.scss | 5 ----- .../files/public/components/file_picker/file_picker.tsx | 2 -- 4 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/file_card.scss delete mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.scss 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 index 32b5092d97de9..6f2f8752668ea 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 @@ -16,6 +16,8 @@ import { isImage } from '../../util'; import { useFilesContext } from '../../context'; import { useFilePickerContext } from '../context'; +import './file_card.scss'; + interface Props { file: FileJSON; } @@ -61,7 +63,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { } description={ <> - <EuiText size="xs"> + <EuiText size="s"> <strong> {file.name}.{file.extension} </strong> 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 deleted file mode 100644 index 1d7bd136a7e69..0000000000000 --- a/x-pack/plugins/files/public/components/file_picker/file_picker.scss +++ /dev/null @@ -1,5 +0,0 @@ -.filesFilePicker { - .euiCard__content { - margin: 0; - } -} \ No newline at end of file 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 3a1305923c7d5..b28f27da9da86 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 @@ -28,8 +28,6 @@ import { UploadFilesPrompt } from './components/upload_files'; import { FileGrid } from './components/file_grid'; import { i18nTexts } from './i18n_texts'; -import './file_picker.scss'; - export interface Props<Kind extends string = string> { /** * The file kind that was passed to the registry. From 1b8c4d62a39415a388ffa72ac5f0a7772f7a32f2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 13:33:44 +0200 Subject: [PATCH 30/77] optimize for filtering files, first pass just filter on names --- .../file_picker/components/file_card.tsx | 7 +- .../file_picker/components/file_grid.tsx | 18 ++-- .../file_picker/components/search_field.tsx | 24 ++++++ .../file_picker/components/select_button.tsx | 29 +++++++ .../file_picker/components/upload_files.tsx | 2 +- .../components/file_picker/file_picker.scss | 6 ++ .../file_picker/file_picker.stories.tsx | 13 +-- .../components/file_picker/file_picker.tsx | 47 ++++++----- .../file_picker/file_picker_state.test.ts | 39 +++++---- .../file_picker/file_picker_state.ts | 83 ++++++++++++++++--- .../components/file_picker/i18n_texts.ts | 6 ++ 11 files changed, 208 insertions(+), 66 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/search_field.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/components/select_button.tsx create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.scss 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 6f2f8752668ea..e3df365d67d9b 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 @@ -17,6 +17,7 @@ import { useFilesContext } from '../../context'; import { useFilePickerContext } from '../context'; import './file_card.scss'; +import { useBehaviorSubject } from '../../use_behavior_subject'; interface Props { file: FileJSON; @@ -27,7 +28,9 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { const { kind, state } = useFilePickerContext(); const { euiTheme } = useEuiTheme(); const displayImage = isImage({ type: file.mimeType }); - const isSelected = state.hasFileId(file.id); + + useBehaviorSubject(state.selectedFileIds$); + const isSelected = state.isFileIdSelected(file.id); const imageHeight = `calc(${euiTheme.size.xxxl} * 2)`; return ( <EuiCard @@ -38,7 +41,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { paddingSize="s" selectable={{ isSelected, - onClick: () => (isSelected ? state.removeFile(file.id) : state.addFile(file.id)), + onClick: () => (isSelected ? state.unselectFile(file.id) : state.selectFile(file.id)), }} image={ <div 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 index 0d17a30934e6c..deeb0cc1ec117 100644 --- 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 @@ -7,17 +7,21 @@ import React from 'react'; import type { FunctionComponent } from 'react'; -import { useEuiTheme } from '@elastic/eui'; +import { useEuiTheme, EuiEmptyPrompt } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { FileJSON } from '../../../../common'; -import { FileCard } from './file_card'; -interface Props { - files: FileJSON[]; -} +import { useBehaviorSubject } from '../../use_behavior_subject'; +import { i18nTexts } from '../i18n_texts'; +import { useFilePickerContext } from '../context'; +import { FileCard } from './file_card'; -export const FileGrid: FunctionComponent<Props> = ({ files }) => { +export const FileGrid: FunctionComponent = () => { + const { state } = useFilePickerContext(); const { euiTheme } = useEuiTheme(); + const files = useBehaviorSubject(state.files$); + if (!files.length) { + return <EuiEmptyPrompt title={<h3>{i18nTexts.emptyFileGridPrompt}</h3>} titleSize="s" />; + } return ( <div css={css` 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..dbd458d639df6 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/search_field.tsx @@ -0,0 +1,24 @@ +/* + * 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'; + +interface Props { + onChange: (filterValue: string) => void; +} + +export const SearchField: FunctionComponent<Props> = ({ onChange }) => { + return ( + <EuiFieldSearch + placeholder={i18nTexts.searchFieldPlaceholder} + onChange={(ev) => onChange(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..485e3ae527d05 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/select_button.tsx @@ -0,0 +1,29 @@ +/* + * 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'; + +interface Props { + onClick: (selectedFiles: string[]) => void; +} + +export const SelectButton: FunctionComponent<Props> = ({ onClick }) => { + const { state } = useFilePickerContext(); + const selectedFiles = useBehaviorSubject(state.selectedFileIds$); + return ( + <EuiButton disabled={!state.hasFilesSelected()} onClick={() => onClick(selectedFiles)}> + {selectedFiles.length > 1 + ? i18nTexts.selectFilesLabel(selectedFiles.length) + : i18nTexts.selectFileLabel} + </EuiButton> + ); +}; 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 index dacee46d84b09..6a235ab6f24b2 100644 --- 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 @@ -21,7 +21,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { <UploadFile kind={kind} immediate - onDone={(file) => state.addFile(file.map(({ id }) => id))} + onDone={(file) => state.selectFile(file.map(({ id }) => id))} initialPromptText={i18nTexts.emptyStatePrompt} /> ); 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..9423fe4e6cf02 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.scss @@ -0,0 +1,6 @@ +.filesFilePicker--fixed { + 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 index 2623bd415580c..774010c1588e9 100644 --- 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 @@ -55,7 +55,7 @@ export const Empty = Template.bind({}); const d = new Date(); let id = 0; -function createFileJSON(): FileJSON { +function createFileJSON(file?: Partial<FileJSON>): FileJSON { return { alt: '', created: d.toISOString(), @@ -71,6 +71,7 @@ function createFileJSON(): FileJSON { name: 'my file', size: 1, status: 'READY', + ...file, }; } export const BasicOne = Template.bind({}); @@ -100,11 +101,11 @@ BasicMany.decorators = [ getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, list: async (): Promise<FilesClientResponses['list']> => ({ files: [ - createFileJSON(), - createFileJSON(), - createFileJSON(), - createFileJSON(), - createFileJSON(), + createFileJSON({ name: 'abc' }), + createFileJSON({ name: 'def' }), + createFileJSON({ name: 'efg' }), + createFileJSON({ name: 'foo' }), + createFileJSON({ name: 'bar' }), createFileJSON(), createFileJSON(), ], 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 b28f27da9da86..34c37ba1889e6 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 @@ -4,15 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; + +import classnames from 'classnames'; +import React, { useEffect } from 'react'; import type { FunctionComponent } from 'react'; import { - EuiButton, - EuiLoadingSpinner, EuiModal, - EuiModalHeader, EuiModalBody, + EuiModalHeader, EuiModalFooter, + EuiLoadingSpinner, } from '@elastic/eui'; import { useQuery } from '@tanstack/react-query'; import { css } from '@emotion/react'; @@ -20,13 +21,16 @@ import { FilePickerContext } from './context'; import { useFilesContext } from '../context'; import { useFilePickerContext } from './context'; -import { useBehaviorSubject } from '../use_behavior_subject'; import { Title } from './components/title'; import { ErrorContent } from './components/error_content'; import { UploadFilesPrompt } from './components/upload_files'; import { FileGrid } from './components/file_grid'; -import { i18nTexts } from './i18n_texts'; +import { SearchField } from './components/search_field'; +import { SelectButton } from './components/select_button'; +import { useBehaviorSubject } from '../use_behavior_subject'; + +import './file_picker.scss'; export interface Props<Kind extends string = string> { /** @@ -50,21 +54,28 @@ export interface Props<Kind extends string = string> { const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { const { client } = useFilesContext(); const { state, kind } = useFilePickerContext(); - const selectedFiles = useBehaviorSubject(state.fileIds$); const { status, error, data } = useQuery({ queryFn: () => client.list({ kind, perPage }), retry: false, }); - const fixedWidth = Boolean(data?.files.length) - ? css` - width: 75vw; - ` - : undefined; + + const hasFiles = useBehaviorSubject(state.hasFiles$); + + useEffect(() => { + if (data?.files.length) state.setFiles(data.files); + }, [data, state]); + + useEffect(() => () => state.dispose(), [state]); return ( - <EuiModal className="filesFilePicker" css={fixedWidth} maxWidth="75vw" onClose={onClose}> + <EuiModal + className={classnames('filesFilePicker', { ['filesFilePicker--fixed']: hasFiles })} + maxWidth="75vw" + onClose={onClose} + > <EuiModalHeader> <Title /> + {hasFiles && <SearchField onChange={state.setQuery} />} </EuiModalHeader> {status === 'loading' ? ( <EuiModalBody @@ -82,21 +93,17 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { > <ErrorContent error={error as Error} /> </EuiModalBody> - ) : data.files.length === 0 ? ( + ) : !hasFiles ? ( <EuiModalBody> <UploadFilesPrompt kind={kind} /> </EuiModalBody> ) : ( <> <EuiModalBody> - <FileGrid files={data.files} /> + <FileGrid /> </EuiModalBody> <EuiModalFooter> - <EuiButton disabled={!state.hasFilesSelected()} onClick={() => onDone(selectedFiles)}> - {selectedFiles.length > 1 - ? i18nTexts.selectFilesLabel(selectedFiles.length) - : i18nTexts.selectFileLabel} - </EuiButton> + <SelectButton onClick={onDone} /> </EuiModalFooter> </> )} 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 index 9cf5ddea18ecd..4229af57154a3 100644 --- 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 @@ -18,38 +18,41 @@ describe('FilePickerState', () => { filePickerState = new FilePickerState(); }); it('starts off empty', () => { - expect(filePickerState.isEmpty()).toBe(true); + expect(filePickerState.hasFilesSelected()).toBe(true); }); it('updates when files are added', () => { getTestScheduler().run(({ expectObservable, cold, flush }) => { - const addFiles$ = cold('--a-b|').pipe(tap((id) => filePickerState.addFile(id))); + const addFiles$ = cold('--a-b|').pipe(tap((id) => filePickerState.selectFile(id))); expectObservable(addFiles$).toBe('--a-b|'); - expectObservable(filePickerState.fileIds$).toBe('a-b-c-', { + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-', { a: [], b: ['a'], c: ['a', 'b'], }); flush(); - expect(filePickerState.isEmpty()).toBe(false); - expect(filePickerState.getFileIds()).toEqual(['a', 'b']); + expect(filePickerState.hasFilesSelected()).toBe(false); + 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.addFile(['1', '2', '3']))); + const addFiles$ = cold('--a|').pipe(tap(() => filePickerState.selectFile(['1', '2', '3']))); expectObservable(addFiles$).toBe('--a|'); - expectObservable(filePickerState.fileIds$).toBe('a-b-', { a: [], b: ['1', '2', '3'] }); + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-', { + a: [], + b: ['1', '2', '3'], + }); flush(); - expect(filePickerState.isEmpty()).toBe(false); - expect(filePickerState.getFileIds()).toEqual(['1', '2', '3']); + expect(filePickerState.hasFilesSelected()).toBe(false); + 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.addFile(id))); - const removeFiles$ = cold('------a|').pipe(tap((id) => filePickerState.removeFile(id))); + 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.fileIds$).toBe('a-b-c-d-e-', { + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-d-e-', { a: [], b: ['a'], c: ['a', 'b'], @@ -57,15 +60,15 @@ describe('FilePickerState', () => { e: ['b', 'c'], }); flush(); - expect(filePickerState.isEmpty()).toBe(false); - expect(filePickerState.getFileIds()).toEqual(['b', 'c']); + expect(filePickerState.hasFilesSelected()).toBe(false); + 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.addFile(id))); + const addFiles$ = cold('--a-b-a-a-a|').pipe(tap((id) => filePickerState.selectFile(id))); expectObservable(addFiles$).toBe('--a-b-a-a-a|'); - expectObservable(filePickerState.fileIds$).toBe('a-b-c-d-e-f-', { + expectObservable(filePickerState.selectedFileIds$).toBe('a-b-c-d-e-f-', { a: [], b: ['a'], c: ['a', 'b'], @@ -74,8 +77,8 @@ describe('FilePickerState', () => { f: ['a', 'b'], }); flush(); - expect(filePickerState.isEmpty()).toBe(false); - expect(filePickerState.getFileIds()).toEqual(['a', 'b']); + expect(filePickerState.hasFilesSelected()).toBe(false); + expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); }); }); }); 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 3caa154afa73e..4b274002c006b 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 @@ -4,40 +4,99 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, distinctUntilChanged, map, Subscription, combineLatest } from 'rxjs'; +import { debounce } from 'lodash'; +import { FileJSON } from '../../../common'; + +const filterFiles = (files: FileJSON[], filter?: string) => { + if (!filter) return files; + + return files.filter((file) => { + return file.name.toLowerCase().includes(filter); + }); +}; export class FilePickerState { + /** + * Files the user has selected + */ + public readonly selectedFileIds$ = new BehaviorSubject<string[]>([]); + + /** + * 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! + */ + public readonly files$ = new BehaviorSubject<FileJSON[]>([]); + public readonly hasFiles$ = new BehaviorSubject<boolean>(false); + + /** + * This is how we keep a deduplicated list of file ids representing files a user + * has selected + */ private readonly fileSet = new Set<string>(); - private sendNext() { - this.fileIds$.next(this.getFileIds()); + private readonly unfilteredFiles$ = new BehaviorSubject<FileJSON[]>([]); + private readonly query$ = new BehaviorSubject<undefined | string>(undefined); + private readonly subscriptions: Subscription[] = []; + + constructor() { + this.subscriptions = [ + this.unfilteredFiles$ + .pipe( + map((files) => files.length > 0), + distinctUntilChanged() + ) + .subscribe(this.hasFiles$), + + combineLatest([this.unfilteredFiles$, this.query$]) + .pipe(map(([files, query]) => filterFiles(files, query))) + .subscribe(this.files$), + ]; } - public fileIds$ = new BehaviorSubject<string[]>([]); - public isEmpty() { - return this.fileSet.size === 0; + private sendNextSelectedFiles() { + this.selectedFileIds$.next(this.getSelectedFileIds()); } public hasFilesSelected = (): boolean => { return this.fileSet.size > 0; }; - public addFile = (fileId: string | string[]): void => { + public selectFile = (fileId: string | string[]): void => { (Array.isArray(fileId) ? fileId : [fileId]).forEach((id) => this.fileSet.add(id)); - this.sendNext(); + this.sendNextSelectedFiles(); }; - public removeFile = (fileId: string): void => { - if (this.fileSet.delete(fileId)) this.sendNext(); + public unselectFile = (fileId: string): void => { + if (this.fileSet.delete(fileId)) this.sendNextSelectedFiles(); }; - public hasFileId = (fileId: string): boolean => { + public isFileIdSelected = (fileId: string): boolean => { return this.fileSet.has(fileId); }; - public getFileIds = (): string[] => { + public getSelectedFileIds = (): string[] => { return Array.from(this.fileSet); }; + + public hasFiles = (): boolean => { + return Boolean(this.files$.getValue().length); + }; + + public setFiles = (files: FileJSON[]): void => { + this.unfilteredFiles$.next(files); + }; + + public setQuery = debounce((query: string): void => { + if (query) this.query$.next(query); + else this.query$.next(undefined); + }, 100); + + public dispose = (): void => { + for (const sub of this.subscriptions) sub.unsubscribe(); + }; } export const createFilePickerState = (): FilePickerState => { 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 f6e3d7594be7e..b80535da0523b 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 @@ -25,4 +25,10 @@ export const i18nTexts = { defaultMessage: 'Select {nrOfFiles} files', values: { nrOfFiles }, }), + searchFieldPlaceholder: i18n.translate('xpack.fileUpload.filePicker.searchFieldPlaceholder', { + defaultMessage: 'Filter by name', + }), + emptyFileGridPrompt: i18n.translate('xpack.fileUpload.filePicker.emptyGridPrompt', { + defaultMessage: 'No files matched your search', + }), }; From ed2c6da95d46a61007c174474606275ff57043b7 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 13:40:22 +0200 Subject: [PATCH 31/77] include xxl --- .../files/public/components/file_picker/file_picker.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 9423fe4e6cf02..a7ec792564500 100644 --- a/x-pack/plugins/files/public/components/file_picker/file_picker.scss +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.scss @@ -1,6 +1,8 @@ .filesFilePicker--fixed { - width: 75vw; - .euiModal__flex { - height: 75vw; + @include euiBreakpoint('m', 'l', 'xl', 'xxl') { + width: 75vw; + .euiModal__flex { + height: 75vw; + } } } \ No newline at end of file From a43cd1bbfa7e4ea646b046b18c5cd37e7059a481 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 15:56:10 +0200 Subject: [PATCH 32/77] moved debounceTime to rxjs land, added test for filtering behaviour --- .../file_picker/file_picker_state.test.ts | 42 ++++++++++++++++--- .../file_picker/file_picker_state.ts | 20 +++++---- 2 files changed, 50 insertions(+), 12 deletions(-) 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 index 4229af57154a3..1bab4820e1a90 100644 --- 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 @@ -5,8 +5,17 @@ * 2.0. */ +jest.mock('rxjs', () => { + const rxjs = jest.requireActual('rxjs'); + return { + ...rxjs, + debounceTime: rxjs.tap, + }; +}); + import { TestScheduler } from 'rxjs/testing'; import { merge, tap } from 'rxjs'; +import { FileJSON } from '../../../common'; import { FilePickerState } from './file_picker_state'; const getTestScheduler = () => @@ -18,7 +27,7 @@ describe('FilePickerState', () => { filePickerState = new FilePickerState(); }); it('starts off empty', () => { - expect(filePickerState.hasFilesSelected()).toBe(true); + expect(filePickerState.hasFilesSelected()).toBe(false); }); it('updates when files are added', () => { getTestScheduler().run(({ expectObservable, cold, flush }) => { @@ -30,7 +39,7 @@ describe('FilePickerState', () => { c: ['a', 'b'], }); flush(); - expect(filePickerState.hasFilesSelected()).toBe(false); + expect(filePickerState.hasFilesSelected()).toBe(true); expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); }); }); @@ -43,7 +52,7 @@ describe('FilePickerState', () => { b: ['1', '2', '3'], }); flush(); - expect(filePickerState.hasFilesSelected()).toBe(false); + expect(filePickerState.hasFilesSelected()).toBe(true); expect(filePickerState.getSelectedFileIds()).toEqual(['1', '2', '3']); }); }); @@ -60,7 +69,7 @@ describe('FilePickerState', () => { e: ['b', 'c'], }); flush(); - expect(filePickerState.hasFilesSelected()).toBe(false); + expect(filePickerState.hasFilesSelected()).toBe(true); expect(filePickerState.getSelectedFileIds()).toEqual(['b', 'c']); }); }); @@ -77,8 +86,31 @@ describe('FilePickerState', () => { f: ['a', 'b'], }); flush(); - expect(filePickerState.hasFilesSelected()).toBe(false); + expect(filePickerState.hasFilesSelected()).toBe(true); expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); }); }); + it('filters files', () => { + getTestScheduler().run(({ expectObservable, cold, time }) => { + const files = [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + ] as FileJSON[]; + filePickerState.setFiles(files); + const inputMarble = '-a-b-c-l|'; + const query$ = cold(inputMarble).pipe( + tap((q) => { + filePickerState.setQuery(q === 'l' ? '' : q); + }) + ); + expectObservable(query$).toBe(inputMarble); + expectObservable(filePickerState.files$).toBe('ab-c-d-e-', { + a: files, // unfiltered + b: [files[0]], // filtered on "a" + c: [files[1]], // filtered on "b" + d: [], // filtered on "c" + e: files, // filtered on "", which should be unfiltered + }); + }); + }); }); 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 4b274002c006b..b9f9047344812 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 @@ -4,15 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { BehaviorSubject, distinctUntilChanged, map, Subscription, combineLatest } from 'rxjs'; -import { debounce } from 'lodash'; +import { + map, + debounceTime, + Subscription, + combineLatest, + BehaviorSubject, + distinctUntilChanged, +} from 'rxjs'; import { FileJSON } from '../../../common'; const filterFiles = (files: FileJSON[], filter?: string) => { if (!filter) return files; - + const lowerFilter = filter.toLowerCase(); return files.filter((file) => { - return file.name.toLowerCase().includes(filter); + return file.name.toLowerCase().includes(lowerFilter); }); }; @@ -50,7 +56,7 @@ export class FilePickerState { ) .subscribe(this.hasFiles$), - combineLatest([this.unfilteredFiles$, this.query$]) + combineLatest([this.unfilteredFiles$, this.query$.pipe(debounceTime(100))]) .pipe(map(([files, query]) => filterFiles(files, query))) .subscribe(this.files$), ]; @@ -89,10 +95,10 @@ export class FilePickerState { this.unfilteredFiles$.next(files); }; - public setQuery = debounce((query: string): void => { + public setQuery = (query: string): void => { if (query) this.query$.next(query); else this.query$.next(undefined); - }, 100); + }; public dispose = (): void => { for (const sub of this.subscriptions) sub.unsubscribe(); From 19b9c58776ec7ecd9e225ce69bfaa69b9a715747 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 16:04:18 +0200 Subject: [PATCH 33/77] added story with more images --- .../file_picker/file_picker.stories.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 index 774010c1588e9..8e46fb7f2772d 100644 --- 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 @@ -117,3 +117,25 @@ BasicMany.decorators = [ </FilesContext> ), ]; + +export const BasicManyMany = Template.bind({}); +BasicManyMany.decorators = [ + (Story) => { + const array = new Array(102); + array.fill(createFileJSON()); + return ( + <FilesContext + client={ + { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, + list: async (): Promise<FilesClientResponses['list']> => ({ + files: array, + }), + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ); + }, +]; From c0cb93f051006a5ffa6c989a0aec6a84db206493 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 12 Oct 2022 23:29:34 +0200 Subject: [PATCH 34/77] big ol wip --- .../components/clear_filter_button.tsx | 35 +++++++++ .../file_picker/components/error_content.tsx | 13 +++- .../file_picker/components/search_field.tsx | 13 ++-- .../public/components/file_picker/context.tsx | 18 +++-- .../file_picker/file_picker.stories.tsx | 22 ++++++ .../components/file_picker/file_picker.tsx | 38 +++++----- .../file_picker/file_picker_state.test.ts | 2 +- .../file_picker/file_picker_state.ts | 76 ++++++++++++++----- .../components/file_picker/i18n_texts.ts | 7 +- 9 files changed, 165 insertions(+), 59 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/clear_filter_button.tsx 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..451c45c01a3e8 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/components/clear_filter_button.tsx @@ -0,0 +1,35 @@ +/* + * 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 { debounceTime } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiLink } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFilePickerContext } from '../context'; + +interface Props { + onClick: () => void; +} + +export const ClearFilterButton: FunctionComponent<Props> = ({ onClick }) => { + const { state } = useFilePickerContext(); + const query = useObservable(state.query$.pipe(debounceTime(100))); + if (!query) { + return null; + } + return ( + <div + css={css` + display: grid; + place-items: center; + `} + > + <EuiLink onClick={onClick}>Clear filter</EuiLink> + </div> + ); +}; 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 index dd0119d7f598c..92e912c8f936d 100644 --- 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 @@ -7,20 +7,27 @@ import React from 'react'; import type { FunctionComponent } from 'react'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18nTexts } from '../i18n_texts'; interface Props { error: Error; + onRetry: () => void; } -export const ErrorContent: FunctionComponent<Props> = ({ error }) => { +export const ErrorContent: FunctionComponent<Props> = ({ error, onRetry }) => { return ( <EuiEmptyPrompt iconType="alert" color="danger" - title={<h2>{i18nTexts.loadingFilesErrorTitle}</h2>} + titleSize="s" + title={<h3>{i18nTexts.loadingFilesErrorTitle}</h3>} body={error.message} + actions={ + <EuiButton onClick={onRetry} color="danger"> + Retry + </EuiButton> + } /> ); }; 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 dbd458d639df6..2239261d2be61 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 @@ -9,16 +9,17 @@ 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'; -interface Props { - onChange: (filterValue: string) => void; -} - -export const SearchField: FunctionComponent<Props> = ({ onChange }) => { +export const SearchField: FunctionComponent = () => { + const { state } = useFilePickerContext(); + const query = useBehaviorSubject(state.query$); return ( <EuiFieldSearch + value={query ?? ''} placeholder={i18nTexts.searchFieldPlaceholder} - onChange={(ev) => onChange(ev.target.value)} + onChange={(ev) => state.setQuery(ev.target.value)} /> ); }; diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index 917f2ded2bade..15eb1c24b827a 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useContext, useMemo, useEffect } from 'react'; import type { FunctionComponent } from 'react'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { useFilesContext } from '../context'; import { FilePickerState, createFilePickerState } from './file_picker_state'; interface FilePickerContextValue { @@ -21,18 +21,20 @@ const FilePickerCtx = createContext<FilePickerContextValue>( interface FilePickerContextProps { kind: string; + pageSize: number; } export const FilePickerContext: FunctionComponent<FilePickerContextProps> = ({ kind, + pageSize, children, }) => { - const client = useMemo(() => new QueryClient(), []); - const state = useMemo(createFilePickerState, []); - return ( - <QueryClientProvider client={client}> - <FilePickerCtx.Provider value={{ state, kind }}>{children}</FilePickerCtx.Provider> - </QueryClientProvider> + const { client } = useFilesContext(); + const state = useMemo( + () => createFilePickerState({ initialPageSize: pageSize, client, kind }), + [pageSize, client, kind] ); + useEffect(() => state.dispose, [state]); + return <FilePickerCtx.Provider value={{ state, kind }}>{children}</FilePickerCtx.Provider>; }; export const useFilePickerContext = (): FilePickerContextValue => { 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 index 8e46fb7f2772d..9f1cfc7a7ae4f 100644 --- 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 @@ -139,3 +139,25 @@ BasicManyMany.decorators = [ ); }, ]; + +export const ErrorLoading = Template.bind({}); +ErrorLoading.decorators = [ + (Story) => { + const array = new Array(102); + array.fill(createFileJSON()); + return ( + <FilesContext + client={ + { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, + list: async () => { + throw new Error('stop'); + }, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ); + }, +]; 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 34c37ba1889e6..c81bf5ad6aa45 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 @@ -14,13 +14,12 @@ import { EuiModalHeader, EuiModalFooter, EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; -import { useQuery } from '@tanstack/react-query'; import { css } from '@emotion/react'; -import { FilePickerContext } from './context'; -import { useFilesContext } from '../context'; -import { useFilePickerContext } from './context'; +import { useBehaviorSubject } from '../use_behavior_subject'; +import { useFilePickerContext, FilePickerContext } from './context'; import { Title } from './components/title'; import { ErrorContent } from './components/error_content'; @@ -28,9 +27,9 @@ import { UploadFilesPrompt } from './components/upload_files'; import { FileGrid } from './components/file_grid'; import { SearchField } from './components/search_field'; import { SelectButton } from './components/select_button'; -import { useBehaviorSubject } from '../use_behavior_subject'; import './file_picker.scss'; +import { ClearFilterButton } from './components/clear_filter_button'; export interface Props<Kind extends string = string> { /** @@ -48,24 +47,19 @@ export interface Props<Kind extends string = string> { /** * The number of results to show per page. */ - perPage?: number; + pageSize?: number; } -const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { - const { client } = useFilesContext(); +const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { const { state, kind } = useFilePickerContext(); - const { status, error, data } = useQuery({ - queryFn: () => client.list({ kind, perPage }), - retry: false, - }); const hasFiles = useBehaviorSubject(state.hasFiles$); + const isLoading = useBehaviorSubject(state.isLoading$); + const error = useBehaviorSubject(state.loadingError$); useEffect(() => { - if (data?.files.length) state.setFiles(data.files); - }, [data, state]); - - useEffect(() => () => state.dispose(), [state]); + state.load(); + }, [state]); return ( <EuiModal @@ -75,9 +69,9 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { > <EuiModalHeader> <Title /> - {hasFiles && <SearchField onChange={state.setQuery} />} + {hasFiles && <SearchField />} </EuiModalHeader> - {status === 'loading' ? ( + {isLoading ? ( <EuiModalBody css={css` place-self: center stretch; @@ -85,13 +79,13 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { > <EuiLoadingSpinner size="xl" /> </EuiModalBody> - ) : status === 'error' ? ( + ) : Boolean(error) ? ( <EuiModalBody css={css` place-self: center stretch; `} > - <ErrorContent error={error as Error} /> + <ErrorContent onRetry={state.load} error={error as Error} /> </EuiModalBody> ) : !hasFiles ? ( <EuiModalBody> @@ -101,6 +95,8 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { <> <EuiModalBody> <FileGrid /> + <EuiSpacer /> + <ClearFilterButton onClick={() => state.setQuery(undefined)} /> </EuiModalBody> <EuiModalFooter> <SelectButton onClick={onDone} /> @@ -112,7 +108,7 @@ const Component: FunctionComponent<Props> = ({ perPage, onClose, onDone }) => { }; export const FilePicker: FunctionComponent<Props> = (props) => ( - <FilePickerContext kind={props.kind}> + <FilePickerContext pageSize={props.pageSize ?? 100} kind={props.kind}> <Component {...props} /> </FilePickerContext> ); 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 index 1bab4820e1a90..e716aa576917a 100644 --- 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 @@ -24,7 +24,7 @@ const getTestScheduler = () => describe('FilePickerState', () => { let filePickerState: FilePickerState; beforeEach(() => { - filePickerState = new FilePickerState(); + filePickerState = new FilePickerState(100); }); it('starts off empty', () => { expect(filePickerState.hasFilesSelected()).toBe(false); 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 b9f9047344812..160673e79b896 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 @@ -11,8 +11,13 @@ import { combineLatest, BehaviorSubject, distinctUntilChanged, + Observable, + from, + finalize, + shareReplay, } from 'rxjs'; import { FileJSON } from '../../../common'; +import { FilesClient } from '../../types'; const filterFiles = (files: FileJSON[], filter?: string) => { if (!filter) return files; @@ -22,6 +27,9 @@ const filterFiles = (files: FileJSON[], filter?: string) => { }); }; +const getFilteredCount = (unfilteredCount: number, filteredCount: number) => + unfilteredCount - filteredCount; + export class FilePickerState { /** * Files the user has selected @@ -35,19 +43,26 @@ export class FilePickerState { * @note This is not explicitly kept in sync with the selected files! */ public readonly files$ = new BehaviorSubject<FileJSON[]>([]); + public readonly isLoading$ = new BehaviorSubject<boolean>(false); + public readonly loadingError$ = new BehaviorSubject<undefined | Error>(undefined); public readonly hasFiles$ = new BehaviorSubject<boolean>(false); + public readonly query$ = new BehaviorSubject<undefined | string>(undefined); + private pageSize$: BehaviorSubject<number>; /** * 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 unfilteredFiles$ = new BehaviorSubject<FileJSON[]>([]); - private readonly query$ = new BehaviorSubject<undefined | string>(undefined); private readonly subscriptions: Subscription[] = []; - constructor() { + constructor( + private readonly client: FilesClient, + private readonly kind: string, + private readonly initialPageSize: number + ) { + this.pageSize$ = new BehaviorSubject<number>(this.initialPageSize); this.subscriptions = [ this.unfilteredFiles$ .pipe( @@ -66,15 +81,39 @@ export class FilePickerState { this.selectedFileIds$.next(this.getSelectedFileIds()); } - public hasFilesSelected = (): boolean => { - return this.fileSet.size > 0; - }; - public selectFile = (fileId: string | string[]): void => { (Array.isArray(fileId) ? fileId : [fileId]).forEach((id) => this.fileSet.add(id)); this.sendNextSelectedFiles(); }; + private loadFiles = (pageSize: number): Observable<void> => { + return from(this.client.list({ kind: this.kind, page: 1, perPage: pageSize })).pipe( + map(({ files }) => { + this.unfilteredFiles$.next(files); + this.pageSize$.next(pageSize); + }), + shareReplay() + ); + }; + + public load = (): Observable<void> => { + this.isLoading$.next(true); + this.loadingError$.next(undefined); + const request$ = this.loadFiles(this.initialPageSize).pipe( + finalize(() => this.isLoading$.next(false)) + ); + request$.subscribe({ error: (e) => this.loadingError$.next(e) }); + return request$; + }; + + public loadMore = (): Observable<void> => { + return this.loadFiles(this.pageSize$.getValue() + this.initialPageSize); + }; + + public hasFilesSelected = (): boolean => { + return this.fileSet.size > 0; + }; + public unselectFile = (fileId: string): void => { if (this.fileSet.delete(fileId)) this.sendNextSelectedFiles(); }; @@ -87,15 +126,7 @@ export class FilePickerState { return Array.from(this.fileSet); }; - public hasFiles = (): boolean => { - return Boolean(this.files$.getValue().length); - }; - - public setFiles = (files: FileJSON[]): void => { - this.unfilteredFiles$.next(files); - }; - - public setQuery = (query: string): void => { + public setQuery = (query: undefined | string): void => { if (query) this.query$.next(query); else this.query$.next(undefined); }; @@ -105,6 +136,15 @@ export class FilePickerState { }; } -export const createFilePickerState = (): FilePickerState => { - return new FilePickerState(); +interface CreateFilePickerArgs { + client: FilesClient; + kind: string; + initialPageSize: number; +} +export const createFilePickerState = ({ + initialPageSize, + client, + kind, +}: CreateFilePickerArgs): FilePickerState => { + return new FilePickerState(client, kind, initialPageSize); }; 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 b80535da0523b..9dac3515bb2b6 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 @@ -12,7 +12,7 @@ export const i18nTexts = { defaultMessage: 'Select a file', }), loadingFilesErrorTitle: i18n.translate('xpack.fileUpload.filePicker.error.loadingTitle', { - defaultMessage: 'Something went wrong while loading files', + defaultMessage: 'Something went wrong', }), emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { defaultMessage: 'No files found, upload your first file.', @@ -29,6 +29,9 @@ export const i18nTexts = { defaultMessage: 'Filter by name', }), emptyFileGridPrompt: i18n.translate('xpack.fileUpload.filePicker.emptyGridPrompt', { - defaultMessage: 'No files matched your search', + defaultMessage: 'No files matched filter', + }), + loadMoreButtonLabel: i18n.translate('xpack.fileUpload.filePicker.loadMoreButtonLabel', { + defaultMessage: 'Load more', }), }; From 1f551dfdf842d56eb786d51bddcd64a43683a90a Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 09:59:14 +0200 Subject: [PATCH 35/77] empty prompt when uploading a file --- .../file_picker/components/upload_files.tsx | 21 ++++++++++++++----- .../components/file_picker/i18n_texts.ts | 5 ++++- 2 files changed, 20 insertions(+), 6 deletions(-) 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 index 6a235ab6f24b2..f542996ab6984 100644 --- 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 @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import { UploadFile } from '../../upload_file'; import { useFilePickerContext } from '../context'; @@ -18,11 +19,21 @@ interface Props { export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { const { state } = useFilePickerContext(); return ( - <UploadFile - kind={kind} - immediate - onDone={(file) => state.selectFile(file.map(({ id }) => id))} - initialPromptText={i18nTexts.emptyStatePrompt} + <EuiEmptyPrompt + title={<h3>{i18nTexts.emptyStatePrompt}</h3>} + body={ + <EuiText color="subdued" size="s"> + <p>{i18nTexts.emptyStatePromptSubtitle}</p> + </EuiText> + } + titleSize="s" + actions={[ + <UploadFile + kind={kind} + immediate + onDone={(file) => state.selectFile(file.map(({ id }) => id))} + />, + ]} /> ); }; 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 9dac3515bb2b6..7902866e0c271 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 @@ -15,7 +15,10 @@ export const i18nTexts = { defaultMessage: 'Something went wrong', }), emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { - defaultMessage: 'No files found, upload your first file.', + defaultMessage: 'No files found', + }), + emptyStatePromptSubtitle: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { + defaultMessage: 'Upload your first file.', }), selectFileLabel: i18n.translate('xpack.fileUpload.filePicker.selectFileButtonLable', { defaultMessage: 'Select file', From 2b93c443459248bf6a564631e2cf0fe3cb483d88 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 10:58:41 +0200 Subject: [PATCH 36/77] added pagination --- .../components/file_picker/file_picker.tsx | 20 +++---- .../file_picker/file_picker_state.ts | 52 +++++++++++-------- 2 files changed, 39 insertions(+), 33 deletions(-) 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 c81bf5ad6aa45..40d7674feca91 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 @@ -5,7 +5,6 @@ * 2.0. */ -import classnames from 'classnames'; import React, { useEffect } from 'react'; import type { FunctionComponent } from 'react'; import { @@ -15,6 +14,7 @@ import { EuiModalFooter, EuiLoadingSpinner, EuiSpacer, + EuiFlexGroup, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -27,6 +27,7 @@ import { UploadFilesPrompt } from './components/upload_files'; import { FileGrid } from './components/file_grid'; import { SearchField } from './components/search_field'; import { SelectButton } from './components/select_button'; +import { Pagination } from './components/pagination'; import './file_picker.scss'; import { ClearFilterButton } from './components/clear_filter_button'; @@ -58,15 +59,11 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { const error = useBehaviorSubject(state.loadingError$); useEffect(() => { - state.load(); + state.loadFiles(); }, [state]); return ( - <EuiModal - className={classnames('filesFilePicker', { ['filesFilePicker--fixed']: hasFiles })} - maxWidth="75vw" - onClose={onClose} - > + <EuiModal className="filesFilePicker filesFilePicker--fixed" maxWidth="75vw" onClose={onClose}> <EuiModalHeader> <Title /> {hasFiles && <SearchField />} @@ -85,7 +82,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { place-self: center stretch; `} > - <ErrorContent onRetry={state.load} error={error as Error} /> + <ErrorContent onRetry={state.loadFiles} error={error as Error} /> </EuiModalBody> ) : !hasFiles ? ( <EuiModalBody> @@ -99,7 +96,10 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { <ClearFilterButton onClick={() => state.setQuery(undefined)} /> </EuiModalBody> <EuiModalFooter> - <SelectButton onClick={onDone} /> + <EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center"> + <Pagination /> + <SelectButton onClick={onDone} /> + </EuiFlexGroup> </EuiModalFooter> </> )} @@ -108,7 +108,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { }; export const FilePicker: FunctionComponent<Props> = (props) => ( - <FilePickerContext pageSize={props.pageSize ?? 100} kind={props.kind}> + <FilePickerContext pageSize={props.pageSize ?? 20} kind={props.kind}> <Component {...props} /> </FilePickerContext> ); 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 160673e79b896..f137d338932ec 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 @@ -5,21 +5,23 @@ * 2.0. */ import { + tap, map, + from, + finalize, + Observable, + shareReplay, debounceTime, Subscription, combineLatest, BehaviorSubject, distinctUntilChanged, - Observable, - from, - finalize, - shareReplay, + combineLatestWith, } from 'rxjs'; import { FileJSON } from '../../../common'; import { FilesClient } from '../../types'; -const filterFiles = (files: FileJSON[], filter?: string) => { +const filterFiles = (files: FileJSON[], filter: undefined | string) => { if (!filter) return files; const lowerFilter = filter.toLowerCase(); return files.filter((file) => { @@ -27,9 +29,6 @@ const filterFiles = (files: FileJSON[], filter?: string) => { }); }; -const getFilteredCount = (unfilteredCount: number, filteredCount: number) => - unfilteredCount - filteredCount; - export class FilePickerState { /** * Files the user has selected @@ -47,8 +46,9 @@ export class FilePickerState { public readonly loadingError$ = new BehaviorSubject<undefined | Error>(undefined); public readonly hasFiles$ = new BehaviorSubject<boolean>(false); public readonly query$ = new BehaviorSubject<undefined | string>(undefined); + public readonly currentPage$ = new BehaviorSubject<number>(0); + public readonly totalPages$ = new BehaviorSubject<undefined | number>(undefined); - private pageSize$: BehaviorSubject<number>; /** * This is how we keep a deduplicated list of file ids representing files a user * has selected @@ -60,9 +60,8 @@ export class FilePickerState { constructor( private readonly client: FilesClient, private readonly kind: string, - private readonly initialPageSize: number + public readonly pageSize: number ) { - this.pageSize$ = new BehaviorSubject<number>(this.initialPageSize); this.subscriptions = [ this.unfilteredFiles$ .pipe( @@ -72,7 +71,16 @@ export class FilePickerState { .subscribe(this.hasFiles$), combineLatest([this.unfilteredFiles$, this.query$.pipe(debounceTime(100))]) - .pipe(map(([files, query]) => filterFiles(files, query))) + .pipe( + map(([files, query]) => filterFiles(files, query)), + // capture total pages after filtering + tap((files) => this.totalPages$.next(Math.ceil(files.length / this.pageSize))), + // then paginate + combineLatestWith(this.currentPage$), + map(([files, page]) => { + return files.slice(page * this.pageSize, (page + 1) * this.pageSize); + }) + ) .subscribe(this.files$), ]; } @@ -86,30 +94,23 @@ export class FilePickerState { this.sendNextSelectedFiles(); }; - private loadFiles = (pageSize: number): Observable<void> => { - return from(this.client.list({ kind: this.kind, page: 1, perPage: pageSize })).pipe( + private sendRequest = (): Observable<void> => { + return from(this.client.list({ kind: this.kind, page: 1, perPage: 1000 })).pipe( map(({ files }) => { this.unfilteredFiles$.next(files); - this.pageSize$.next(pageSize); }), shareReplay() ); }; - public load = (): Observable<void> => { + public loadFiles = (): Observable<void> => { this.isLoading$.next(true); this.loadingError$.next(undefined); - const request$ = this.loadFiles(this.initialPageSize).pipe( - finalize(() => this.isLoading$.next(false)) - ); + const request$ = this.sendRequest().pipe(finalize(() => this.isLoading$.next(false))); request$.subscribe({ error: (e) => this.loadingError$.next(e) }); return request$; }; - public loadMore = (): Observable<void> => { - return this.loadFiles(this.pageSize$.getValue() + this.initialPageSize); - }; - public hasFilesSelected = (): boolean => { return this.fileSet.size > 0; }; @@ -129,6 +130,11 @@ export class FilePickerState { public setQuery = (query: undefined | string): void => { if (query) this.query$.next(query); else this.query$.next(undefined); + this.currentPage$.next(1); + }; + + public setPage = (page: number): void => { + this.currentPage$.next(page); }; public dispose = (): void => { From 24994ae3747c965ca40102a467282c6d0d5ded45 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 12:06:08 +0200 Subject: [PATCH 37/77] fixed tests and added some comments --- .../file_picker/components/pagination.tsx | 19 ++++++++ .../file_picker/components/upload_files.tsx | 1 + .../public/components/file_picker/context.tsx | 2 +- .../file_picker/file_picker_state.test.ts | 45 ++++++++++++------- .../file_picker/file_picker_state.ts | 23 ++++++---- 5 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/pagination.tsx 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 <EuiPagination onPageClick={state.setPage} pageCount={pageCount} activePage={page} />; +}; 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 index f542996ab6984..a795cbb0723ab 100644 --- 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 @@ -28,6 +28,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { } titleSize="s" actions={[ + // TODO: We can remove this once the entire modal is an upload area <UploadFile kind={kind} immediate diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index 15eb1c24b827a..568161ece5bc5 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -30,7 +30,7 @@ export const FilePickerContext: FunctionComponent<FilePickerContextProps> = ({ }) => { const { client } = useFilesContext(); const state = useMemo( - () => createFilePickerState({ initialPageSize: pageSize, client, kind }), + () => createFilePickerState({ pageSize: pageSize, client, kind }), [pageSize, client, kind] ); useEffect(() => state.dispose, [state]); 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 index e716aa576917a..d53eda3d4450f 100644 --- 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 @@ -14,17 +14,24 @@ jest.mock('rxjs', () => { }); import { TestScheduler } from 'rxjs/testing'; -import { merge, tap } from 'rxjs'; +import { merge, tap, of } from 'rxjs'; import { FileJSON } from '../../../common'; -import { FilePickerState } from './file_picker_state'; +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(() => { - filePickerState = new FilePickerState(100); + filesClient = createMockFilesClient(); + filePickerState = createFilePickerState({ + client: filesClient, + pageSize: 20, + kind: 'test', + }); }); it('starts off empty', () => { expect(filePickerState.hasFilesSelected()).toBe(false); @@ -90,26 +97,30 @@ describe('FilePickerState', () => { expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); }); }); - it('filters files', () => { - getTestScheduler().run(({ expectObservable, cold, time }) => { - const files = [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - ] as FileJSON[]; - filePickerState.setFiles(files); - const inputMarble = '-a-b-c-l|'; + it('loads and filters files', () => { + const files = [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + ] as FileJSON[]; + filesClient.list.mockImplementation(() => of({ files }) as any); + getTestScheduler().run(({ expectObservable, cold }) => { + const loadFiles$ = cold('a|').pipe(tap(() => filePickerState.loadFiles())); + expectObservable(loadFiles$).toBe('a|'); + expectObservable(filePickerState.isLoading$).toBe('(010)-', [false, true, false]); + const inputMarble = '-----a--b--c--l|'; const query$ = cold(inputMarble).pipe( tap((q) => { filePickerState.setQuery(q === 'l' ? '' : q); }) ); expectObservable(query$).toBe(inputMarble); - expectObservable(filePickerState.files$).toBe('ab-c-d-e-', { - a: files, // unfiltered - b: [files[0]], // filtered on "a" - c: [files[1]], // filtered on "b" - d: [], // filtered on "c" - e: files, // filtered on "", which should be unfiltered + expectObservable(filePickerState.files$).toBe('(ab)-c--d--e--f', { + a: [], // init + b: files, // unfiltered + c: [files[0]], // filtered on "a" + d: [files[1]], // filtered on "b" + e: [], // filtered on "c" + f: files, // filtered on "", which should be unfiltered }); }); }); 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 f137d338932ec..54999e4afcb16 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 @@ -76,10 +76,9 @@ export class FilePickerState { // capture total pages after filtering tap((files) => this.totalPages$.next(Math.ceil(files.length / this.pageSize))), // then paginate - combineLatestWith(this.currentPage$), - map(([files, page]) => { - return files.slice(page * this.pageSize, (page + 1) * this.pageSize); - }) + combineLatestWith(this.currentPage$.pipe(distinctUntilChanged())), + map(([files, page]) => files.slice(page * this.pageSize, (page + 1) * this.pageSize)), + shareReplay() ) .subscribe(this.files$), ]; @@ -95,7 +94,13 @@ export class FilePickerState { }; private sendRequest = (): Observable<void> => { - return from(this.client.list({ kind: this.kind, page: 1, perPage: 1000 })).pipe( + return from( + this.client.list({ + kind: this.kind, + page: 1, + perPage: 1000 /* TODO: we should filter server side */, + }) + ).pipe( map(({ files }) => { this.unfilteredFiles$.next(files); }), @@ -130,7 +135,7 @@ export class FilePickerState { public setQuery = (query: undefined | string): void => { if (query) this.query$.next(query); else this.query$.next(undefined); - this.currentPage$.next(1); + this.currentPage$.next(0); }; public setPage = (page: number): void => { @@ -145,12 +150,12 @@ export class FilePickerState { interface CreateFilePickerArgs { client: FilesClient; kind: string; - initialPageSize: number; + pageSize: number; } export const createFilePickerState = ({ - initialPageSize, + pageSize, client, kind, }: CreateFilePickerArgs): FilePickerState => { - return new FilePickerState(client, kind, initialPageSize); + return new FilePickerState(client, kind, pageSize); }; From 690a38771887968c1b2dd003338201ecad561db4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 12:09:34 +0200 Subject: [PATCH 38/77] address lint --- x-pack/plugins/files/public/components/file_picker/context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index 568161ece5bc5..045e405665a47 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -30,7 +30,7 @@ export const FilePickerContext: FunctionComponent<FilePickerContextProps> = ({ }) => { const { client } = useFilesContext(); const state = useMemo( - () => createFilePickerState({ pageSize: pageSize, client, kind }), + () => createFilePickerState({ pageSize, client, kind }), [pageSize, client, kind] ); useEffect(() => state.dispose, [state]); From 70efb7e1606175a7742e889f2fa346f5135bf0df Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 12:38:42 +0200 Subject: [PATCH 39/77] moved copy to i18n and updated size and color of empty error prompt --- .../file_picker/components/error_content.tsx | 12 ++++++++---- .../public/components/file_picker/i18n_texts.ts | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) 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 index 92e912c8f936d..bed61e4f314e9 100644 --- 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 @@ -9,6 +9,8 @@ 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; @@ -16,16 +18,18 @@ interface Props { } export const ErrorContent: FunctionComponent<Props> = ({ error, onRetry }) => { + const { state } = useFilePickerContext(); + const isLoading = useBehaviorSubject(state.isLoading$); return ( <EuiEmptyPrompt iconType="alert" - color="danger" - titleSize="s" + iconColor="danger" + titleSize="xs" title={<h3>{i18nTexts.loadingFilesErrorTitle}</h3>} body={error.message} actions={ - <EuiButton onClick={onRetry} color="danger"> - Retry + <EuiButton disabled={isLoading} onClick={onRetry}> + {i18nTexts.retryButtonLabel} </EuiButton> } /> 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 7902866e0c271..f6f17728786a4 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 @@ -12,7 +12,10 @@ export const i18nTexts = { defaultMessage: 'Select a file', }), loadingFilesErrorTitle: i18n.translate('xpack.fileUpload.filePicker.error.loadingTitle', { - defaultMessage: 'Something went wrong', + defaultMessage: 'Could not load files', + }), + retryButtonLabel: i18n.translate('xpack.fileUpload.filePicker.error.retryButtonLabel', { + defaultMessage: 'Retry', }), emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { defaultMessage: 'No files found', From 94696488cfc81aa5162c81881ee8a221ffac43b9 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 12:39:27 +0200 Subject: [PATCH 40/77] remove use of css` --- .../public/components/file_picker/file_picker.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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 40d7674feca91..9a7b0423d044a 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 @@ -16,7 +16,6 @@ import { EuiSpacer, EuiFlexGroup, } from '@elastic/eui'; -import { css } from '@emotion/react'; import { useBehaviorSubject } from '../use_behavior_subject'; import { useFilePickerContext, FilePickerContext } from './context'; @@ -69,19 +68,11 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { {hasFiles && <SearchField />} </EuiModalHeader> {isLoading ? ( - <EuiModalBody - css={css` - place-self: center stretch; - `} - > + <EuiModalBody> <EuiLoadingSpinner size="xl" /> </EuiModalBody> ) : Boolean(error) ? ( - <EuiModalBody - css={css` - place-self: center stretch; - `} - > + <EuiModalBody> <ErrorContent onRetry={state.loadFiles} error={error as Error} /> </EuiModalBody> ) : !hasFiles ? ( From 8b33acc3fb00cd6a5b0014522809782e5858bf74 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 12:41:38 +0200 Subject: [PATCH 41/77] remove non existant prop --- x-pack/examples/files_example/public/components/modal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/x-pack/examples/files_example/public/components/modal.tsx b/x-pack/examples/files_example/public/components/modal.tsx index 9d323b240f416..67b2504c5aaab 100644 --- a/x-pack/examples/files_example/public/components/modal.tsx +++ b/x-pack/examples/files_example/public/components/modal.tsx @@ -26,12 +26,7 @@ export const Modal: FunctionComponent<Props> = ({ onDismiss, onUploaded, client </EuiText> </EuiModalHeader> <EuiModalBody> - <UploadFile - kind={exampleFileKind.id} - client={client} - onDone={onUploaded} - meta={{ custom: 'meta' }} - /> + <UploadFile kind={exampleFileKind.id} onDone={onUploaded} meta={{ custom: 'meta' }} /> </EuiModalBody> </EuiModal> ); From 2bb31ea5686c1b22477d5d86edbf8d5f57db563a Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 13:51:27 +0200 Subject: [PATCH 42/77] also reload files --- .../components/file_picker/components/upload_files.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index a795cbb0723ab..1e5ea5c79e807 100644 --- 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 @@ -32,7 +32,10 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { <UploadFile kind={kind} immediate - onDone={(file) => state.selectFile(file.map(({ id }) => id))} + onDone={(file) => { + state.selectFile(file.map(({ id }) => id)); + state.loadFiles(); + }} />, ]} /> From ec346b14644af0d2edbd078233350fc5eea56085 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 15:37:31 +0200 Subject: [PATCH 43/77] fileUpload -> files --- .../components/file_picker/i18n_texts.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 f6f17728786a4..ccb39930dd417 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 @@ -8,36 +8,36 @@ import { i18n } from '@kbn/i18n'; export const i18nTexts = { - title: i18n.translate('xpack.fileUpload.filePicker.title', { + title: i18n.translate('xpack.files.filePicker.title', { defaultMessage: 'Select a file', }), - loadingFilesErrorTitle: i18n.translate('xpack.fileUpload.filePicker.error.loadingTitle', { + loadingFilesErrorTitle: i18n.translate('xpack.files.filePicker.error.loadingTitle', { defaultMessage: 'Could not load files', }), - retryButtonLabel: i18n.translate('xpack.fileUpload.filePicker.error.retryButtonLabel', { + retryButtonLabel: i18n.translate('xpack.files.filePicker.error.retryButtonLabel', { defaultMessage: 'Retry', }), - emptyStatePrompt: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { + emptyStatePrompt: i18n.translate('xpack.files.filePicker.emptyStatePrompt', { defaultMessage: 'No files found', }), - emptyStatePromptSubtitle: i18n.translate('xpack.fileUpload.filePicker.emptyStatePrompt', { + emptyStatePromptSubtitle: i18n.translate('xpack.files.filePicker.emptyStatePrompt', { defaultMessage: 'Upload your first file.', }), - selectFileLabel: i18n.translate('xpack.fileUpload.filePicker.selectFileButtonLable', { + selectFileLabel: i18n.translate('xpack.files.filePicker.selectFileButtonLable', { defaultMessage: 'Select file', }), selectFilesLabel: (nrOfFiles: number) => - i18n.translate('xpack.fileUpload.filePicker.selectFilesButtonLable', { + i18n.translate('xpack.files.filePicker.selectFilesButtonLable', { defaultMessage: 'Select {nrOfFiles} files', values: { nrOfFiles }, }), - searchFieldPlaceholder: i18n.translate('xpack.fileUpload.filePicker.searchFieldPlaceholder', { + searchFieldPlaceholder: i18n.translate('xpack.files.filePicker.searchFieldPlaceholder', { defaultMessage: 'Filter by name', }), - emptyFileGridPrompt: i18n.translate('xpack.fileUpload.filePicker.emptyGridPrompt', { + emptyFileGridPrompt: i18n.translate('xpack.files.filePicker.emptyGridPrompt', { defaultMessage: 'No files matched filter', }), - loadMoreButtonLabel: i18n.translate('xpack.fileUpload.filePicker.loadMoreButtonLabel', { + loadMoreButtonLabel: i18n.translate('xpack.files.filePicker.loadMoreButtonLabel', { defaultMessage: 'Load more', }), }; From 424879cef14eccff1e5152d1cfd65ed9d98f8a86 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 16:09:58 +0200 Subject: [PATCH 44/77] update logic for watching if selected --- .../public/components/file_picker/components/file_card.tsx | 6 +++--- .../public/components/file_picker/file_picker_state.ts | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) 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 e3df365d67d9b..0f5b6d3bcfd73 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 @@ -8,6 +8,7 @@ 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 { FileJSON } from '../../../../common'; @@ -17,7 +18,6 @@ import { useFilesContext } from '../../context'; import { useFilePickerContext } from '../context'; import './file_card.scss'; -import { useBehaviorSubject } from '../../use_behavior_subject'; interface Props { file: FileJSON; @@ -29,8 +29,8 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { const { euiTheme } = useEuiTheme(); const displayImage = isImage({ type: file.mimeType }); - useBehaviorSubject(state.selectedFileIds$); - const isSelected = state.isFileIdSelected(file.id); + const isSelected = useObservable(state.watchFileSelected$(file.id), false); + const imageHeight = `calc(${euiTheme.size.xxxl} * 2)`; return ( <EuiCard 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 54999e4afcb16..e7bd582da0f9e 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 @@ -145,6 +145,13 @@ export class FilePickerState { 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 { From 126bfb65a91d3b430abefbb468174df209261340 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 13 Oct 2022 18:51:29 +0200 Subject: [PATCH 45/77] disambiguate i18n ids --- .../plugins/files/public/components/file_picker/i18n_texts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ccb39930dd417..42c33df6e488a 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 @@ -20,7 +20,7 @@ export const i18nTexts = { emptyStatePrompt: i18n.translate('xpack.files.filePicker.emptyStatePrompt', { defaultMessage: 'No files found', }), - emptyStatePromptSubtitle: i18n.translate('xpack.files.filePicker.emptyStatePrompt', { + emptyStatePromptSubtitle: i18n.translate('xpack.files.filePicker.emptyStatePromptSubtitle', { defaultMessage: 'Upload your first file.', }), selectFileLabel: i18n.translate('xpack.files.filePicker.selectFileButtonLable', { From b576308c4da676f5e90c133d45569c44d527d687 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 14:48:54 +0200 Subject: [PATCH 46/77] use abort signal and call sendRequest from file$, filtering done server side now, update tests --- .../file_picker/file_picker_state.test.ts | 77 ++++++++++----- .../file_picker/file_picker_state.ts | 94 +++++++++---------- .../files/public/files_client/files_client.ts | 3 +- x-pack/plugins/files/public/types.ts | 4 +- 4 files changed, 102 insertions(+), 76 deletions(-) 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 index d53eda3d4450f..6aeb0540c4a29 100644 --- 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 @@ -97,30 +97,59 @@ describe('FilePickerState', () => { expect(filePickerState.getSelectedFileIds()).toEqual(['a', 'b']); }); }); - it('loads and filters files', () => { - const files = [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - ] as FileJSON[]; - filesClient.list.mockImplementation(() => of({ files }) as any); - getTestScheduler().run(({ expectObservable, cold }) => { - const loadFiles$ = cold('a|').pipe(tap(() => filePickerState.loadFiles())); - expectObservable(loadFiles$).toBe('a|'); - expectObservable(filePickerState.isLoading$).toBe('(010)-', [false, true, false]); - const inputMarble = '-----a--b--c--l|'; - const query$ = cold(inputMarble).pipe( - tap((q) => { - filePickerState.setQuery(q === 'l' ? '' : q); - }) - ); - expectObservable(query$).toBe(inputMarble); - expectObservable(filePickerState.files$).toBe('(ab)-c--d--e--f', { - a: [], // init - b: files, // unfiltered - c: [files[0]], // filtered on "a" - d: [files[1]], // filtered on "b" - e: [], // filtered on "c" - f: files, // filtered on "", which should be unfiltered + 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, + }); + expect(filesClient.list).toHaveBeenNthCalledWith(2, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['a'], + page: 1, + perPage: 20, + }); + expect(filesClient.list).toHaveBeenNthCalledWith(3, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['b'], + page: 1, + perPage: 20, + }); + expect(filesClient.list).toHaveBeenNthCalledWith(4, { + abortSignal: expect.any(AbortSignal), + kind: 'test', + name: ['b'], + page: 3, + perPage: 20, }); }); }); 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 e7bd582da0f9e..127a9ab3a604e 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 @@ -5,10 +5,11 @@ * 2.0. */ import { - tap, map, + tap, from, finalize, + switchMap, Observable, shareReplay, debounceTime, @@ -16,45 +17,43 @@ import { combineLatest, BehaviorSubject, distinctUntilChanged, - combineLatestWith, } from 'rxjs'; import { FileJSON } from '../../../common'; import { FilesClient } from '../../types'; -const filterFiles = (files: FileJSON[], filter: undefined | string) => { - if (!filter) return files; - const lowerFilter = filter.toLowerCase(); - return files.filter((file) => { - return file.name.toLowerCase().includes(lowerFilter); - }); -}; - export class FilePickerState { /** * Files the user has selected */ public readonly selectedFileIds$ = new BehaviorSubject<string[]>([]); - /** - * 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! - */ - public readonly files$ = new BehaviorSubject<FileJSON[]>([]); public readonly isLoading$ = new BehaviorSubject<boolean>(false); public readonly loadingError$ = new BehaviorSubject<undefined | Error>(undefined); public readonly hasFiles$ = new BehaviorSubject<boolean>(false); public readonly query$ = new BehaviorSubject<undefined | string>(undefined); public readonly currentPage$ = new BehaviorSubject<number>(0); public readonly totalPages$ = new BehaviorSubject<undefined | number>(undefined); + /** + * 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! + */ + public readonly files$ = combineLatest([ + this.currentPage$.pipe(distinctUntilChanged()), + this.query$.pipe(distinctUntilChanged(), debounceTime(100)), + ]).pipe( + switchMap(([page, query]) => this.sendRequest(page, query)), + tap(({ total }) => this.updateTotalPages({ total })), + map(({ files }) => files), + shareReplay() + ); /** * 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 unfilteredFiles$ = new BehaviorSubject<FileJSON[]>([]); private readonly subscriptions: Subscription[] = []; constructor( @@ -62,28 +61,13 @@ export class FilePickerState { private readonly kind: string, public readonly pageSize: number ) { - this.subscriptions = [ - this.unfilteredFiles$ - .pipe( - map((files) => files.length > 0), - distinctUntilChanged() - ) - .subscribe(this.hasFiles$), - - combineLatest([this.unfilteredFiles$, this.query$.pipe(debounceTime(100))]) - .pipe( - map(([files, query]) => filterFiles(files, query)), - // capture total pages after filtering - tap((files) => this.totalPages$.next(Math.ceil(files.length / this.pageSize))), - // then paginate - combineLatestWith(this.currentPage$.pipe(distinctUntilChanged())), - map(([files, page]) => files.slice(page * this.pageSize, (page + 1) * this.pageSize)), - shareReplay() - ) - .subscribe(this.files$), - ]; + this.subscriptions = []; } + private updateTotalPages = ({ total }: { total: number }): void => { + this.totalPages$.next(Math.ceil(total / this.pageSize)); + }; + private sendNextSelectedFiles() { this.selectedFileIds$.next(this.getSelectedFileIds()); } @@ -93,26 +77,36 @@ export class FilePickerState { this.sendNextSelectedFiles(); }; - private sendRequest = (): Observable<void> => { - return from( + private abort: undefined | (() => void) = undefined; + private sendRequest = ( + page: number, + query: undefined | string + ): Observable<{ files: FileJSON[]; total: number }> => { + if (this.abort) this.abort(); + this.isLoading$.next(true); + this.loadingError$.next(undefined); + + const abortController = new AbortController(); + this.abort = () => { + abortController.abort(); + }; + + const request$ = from( this.client.list({ kind: this.kind, - page: 1, - perPage: 1000 /* TODO: we should filter server side */, + name: query ? [query] : undefined, + page: page + 1, + perPage: this.pageSize, + abortSignal: abortController.signal, }) ).pipe( - map(({ files }) => { - this.unfilteredFiles$.next(files); + finalize(() => { + this.isLoading$.next(false); + this.abort = undefined; }), shareReplay() ); - }; - public loadFiles = (): Observable<void> => { - this.isLoading$.next(true); - this.loadingError$.next(undefined); - const request$ = this.sendRequest().pipe(finalize(() => this.isLoading$.next(false))); - request$.subscribe({ error: (e) => this.loadingError$.next(e) }); return request$; }; 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/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']>; /** From 954a5a4c5fc0c36f32689902c32902b2d7c313cb Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:05:27 +0200 Subject: [PATCH 47/77] fix a few off by one errors and hook up the new system to the ui --- .../file_picker/components/error_content.tsx | 5 +- .../file_picker/components/file_grid.tsx | 4 +- .../file_picker/file_picker.stories.tsx | 52 +++++++++++-------- .../components/file_picker/file_picker.tsx | 15 ++++-- .../file_picker/file_picker_state.ts | 45 ++++++++++------ 5 files changed, 75 insertions(+), 46 deletions(-) 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 index bed61e4f314e9..32ecf23037cc2 100644 --- 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 @@ -14,10 +14,9 @@ import { useBehaviorSubject } from '../../use_behavior_subject'; interface Props { error: Error; - onRetry: () => void; } -export const ErrorContent: FunctionComponent<Props> = ({ error, onRetry }) => { +export const ErrorContent: FunctionComponent<Props> = ({ error }) => { const { state } = useFilePickerContext(); const isLoading = useBehaviorSubject(state.isLoading$); return ( @@ -28,7 +27,7 @@ export const ErrorContent: FunctionComponent<Props> = ({ error, onRetry }) => { title={<h3>{i18nTexts.loadingFilesErrorTitle}</h3>} body={error.message} actions={ - <EuiButton disabled={isLoading} onClick={onRetry}> + <EuiButton disabled={isLoading} onClick={state.retry}> {i18nTexts.retryButtonLabel} </EuiButton> } 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 index deeb0cc1ec117..c1790869c4bb3 100644 --- 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 @@ -9,8 +9,8 @@ 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 { useBehaviorSubject } from '../../use_behavior_subject'; import { i18nTexts } from '../i18n_texts'; import { useFilePickerContext } from '../context'; import { FileCard } from './file_card'; @@ -18,7 +18,7 @@ import { FileCard } from './file_card'; export const FileGrid: FunctionComponent = () => { const { state } = useFilePickerContext(); const { euiTheme } = useEuiTheme(); - const files = useBehaviorSubject(state.files$); + const files = useObservable(state.files$, []); if (!files.length) { return <EuiEmptyPrompt title={<h3>{i18nTexts.emptyFileGridPrompt}</h3>} titleSize="s" />; } 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 index 9f1cfc7a7ae4f..b4b59869a90af 100644 --- 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 @@ -39,6 +39,7 @@ export default { create: () => Promise.reject(new Error('not so fast buster!')), list: async (): Promise<FilesClientResponses['list']> => ({ files: [], + total: 0, }), } as unknown as FilesClient } @@ -83,6 +84,7 @@ BasicOne.decorators = [ getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, list: async (): Promise<FilesClientResponses['list']> => ({ files: [createFileJSON()], + total: 1, }), } as unknown as FilesClient } @@ -94,28 +96,33 @@ BasicOne.decorators = [ export const BasicMany = Template.bind({}); BasicMany.decorators = [ - (Story) => ( - <FilesContext - client={ - { - getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, - list: async (): Promise<FilesClientResponses['list']> => ({ - files: [ - createFileJSON({ name: 'abc' }), - createFileJSON({ name: 'def' }), - createFileJSON({ name: 'efg' }), - createFileJSON({ name: 'foo' }), - createFileJSON({ name: 'bar' }), - createFileJSON(), - createFileJSON(), - ], - }), - } as unknown as FilesClient - } - > - <Story /> - </FilesContext> - ), + (Story) => { + const files = [ + createFileJSON({ name: 'abc' }), + createFileJSON({ name: 'def' }), + createFileJSON({ name: 'efg' }), + createFileJSON({ name: 'foo' }), + createFileJSON({ name: 'bar' }), + createFileJSON(), + createFileJSON(), + ]; + + return ( + <FilesContext + client={ + { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, + list: async (): Promise<FilesClientResponses['list']> => ({ + files, + total: files.length, + }), + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + ); + }, ]; export const BasicManyMany = Template.bind({}); @@ -130,6 +137,7 @@ BasicManyMany.decorators = [ getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, list: async (): Promise<FilesClientResponses['list']> => ({ files: array, + total: array.length, }), } as unknown as FilesClient } 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 9a7b0423d044a..d205cdc18784c 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 { css } from '@emotion/react'; import { useBehaviorSubject } from '../use_behavior_subject'; import { useFilePickerContext, FilePickerContext } from './context'; @@ -58,7 +59,8 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { const error = useBehaviorSubject(state.loadingError$); useEffect(() => { - state.loadFiles(); + const sub = state.files$.subscribe(); + return () => sub.unsubscribe(); }, [state]); return ( @@ -69,11 +71,18 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { </EuiModalHeader> {isLoading ? ( <EuiModalBody> - <EuiLoadingSpinner size="xl" /> + <div + css={css` + display: grid; + place-items: center; + `} + > + <EuiLoadingSpinner size="xl" /> + </div> </EuiModalBody> ) : Boolean(error) ? ( <EuiModalBody> - <ErrorContent onRetry={state.loadFiles} error={error as Error} /> + <ErrorContent error={error as Error} /> </EuiModalBody> ) : !hasFiles ? ( <EuiModalBody> 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 127a9ab3a604e..9535f422c6d0c 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 @@ -27,33 +27,19 @@ export class FilePickerState { */ public readonly selectedFileIds$ = new BehaviorSubject<string[]>([]); - public readonly isLoading$ = new BehaviorSubject<boolean>(false); + public readonly isLoading$ = new BehaviorSubject<boolean>(true); public readonly loadingError$ = new BehaviorSubject<undefined | Error>(undefined); public readonly hasFiles$ = new BehaviorSubject<boolean>(false); public readonly query$ = new BehaviorSubject<undefined | string>(undefined); public readonly currentPage$ = new BehaviorSubject<number>(0); public readonly totalPages$ = new BehaviorSubject<undefined | number>(undefined); - /** - * 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! - */ - public readonly files$ = combineLatest([ - this.currentPage$.pipe(distinctUntilChanged()), - this.query$.pipe(distinctUntilChanged(), debounceTime(100)), - ]).pipe( - switchMap(([page, query]) => this.sendRequest(page, query)), - tap(({ total }) => this.updateTotalPages({ total })), - map(({ files }) => files), - shareReplay() - ); /** * 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[] = []; constructor( @@ -64,6 +50,25 @@ export class FilePickerState { this.subscriptions = []; } + /** + * 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)); }; @@ -107,9 +112,17 @@ export class FilePickerState { shareReplay() ); + request$.subscribe({ + error: (e) => this.loadingError$.next(e), + }); + return request$; }; + public retry = (): void => { + this.retry$.next(); + }; + public hasFilesSelected = (): boolean => { return this.fileSet.size > 0; }; From 6aac9355540c27f2dc3576c594bbf4992ed13a19 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:17:55 +0200 Subject: [PATCH 48/77] added test for in flight requests behaviour --- .../file_picker/file_picker_state.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index 6aeb0540c4a29..9c0226bf2b54a 100644 --- 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 @@ -14,7 +14,7 @@ jest.mock('rxjs', () => { }); import { TestScheduler } from 'rxjs/testing'; -import { merge, tap, of } from 'rxjs'; +import { merge, tap, of, NEVER } from 'rxjs'; import { FileJSON } from '../../../common'; import { FilePickerState, createFilePickerState } from './file_picker_state'; import { createMockFilesClient } from '../../mocks'; @@ -153,4 +153,15 @@ describe('FilePickerState', () => { }); }); }); + 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-', {}); + }); + }); }); From 65c5a7545d1eefe03b2ec2cf99713deae6e8b231 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:28:15 +0200 Subject: [PATCH 49/77] update the files example app --- .../examples/files_example/public/components/app.tsx | 10 ++++++++++ x-pack/examples/files_example/public/imports.ts | 1 + .../public/components/file_picker/file_picker.tsx | 3 +++ .../files/public/components/file_picker/index.ts | 6 ------ x-pack/plugins/files/public/components/index.ts | 1 + x-pack/plugins/files/public/index.ts | 2 ++ 6 files changed, 17 insertions(+), 6 deletions(-) delete mode 100644 x-pack/plugins/files/public/components/file_picker/index.ts diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index cf0f4461b8b62..929cf00040efb 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<undefined | FileJSON<MyImageMetadata>>(); const renderToolsRight = () => { return [ + <EuiButton + onClick={() => setShowFilePickerModal(true)} + isDisabled={isLoading || isDeletingFile} + iconType="eye" + > + Pick a file + </EuiButton>, <EuiButton onClick={() => setShowUploadModal(true)} isDisabled={isLoading || isDeletingFile} @@ -155,6 +164,7 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = }} /> )} + {showFilePickerModal && <MyFilePicker onClose={() => {}} onDone={() => {}} />} </> ); }; diff --git a/x-pack/examples/files_example/public/imports.ts b/x-pack/examples/files_example/public/imports.ts index 7758883d0da83..bd58dc021394d 100644 --- a/x-pack/examples/files_example/public/imports.ts +++ b/x-pack/examples/files_example/public/imports.ts @@ -12,5 +12,6 @@ export { UploadFile, FilesContext, ScopedFilesClient, + FilePicker, Image, } from '@kbn/files-plugin/public'; 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 d205cdc18784c..6a28c01ada049 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 @@ -112,3 +112,6 @@ export const FilePicker: FunctionComponent<Props> = (props) => ( <Component {...props} /> </FilePickerContext> ); + +/* eslint-disable import/no-default-export */ +export default FilePicker; diff --git a/x-pack/plugins/files/public/components/file_picker/index.ts b/x-pack/plugins/files/public/components/file_picker/index.ts deleted file mode 100644 index 1fec1c76430eb..0000000000000 --- a/x-pack/plugins/files/public/components/file_picker/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * 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. - */ 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/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() { From 2689c9b2b0f06cd8ff41b0674834d2005c2b37e1 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:38:03 +0200 Subject: [PATCH 50/77] fix minor card layout styling to make all cards the same size regardless of text or image conten --- .../components/file_picker/components/file_card.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 0f5b6d3bcfd73..e6e63d5af3518 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 @@ -36,7 +36,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { <EuiCard title="" css={css` - place-self: center; + place-self: stretch; `} paddingSize="s" selectable={{ @@ -49,6 +49,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { display: grid; place-items: center; height: ${imageHeight}; + margin: ${euiTheme.size.m}; `} > {displayImage ? ( @@ -60,7 +61,15 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { src={client.getDownloadHref({ id: file.id, fileKind: kind })} /> ) : ( - <EuiIcon type="filebeatApp" size="xl" /> + <div + css={css` + display: grid; + place-items: center; + height: ${imageHeight}; + `} + > + <EuiIcon type="filebeatApp" size="xl" /> + </div> )} </div> } From beb036023b0382796971bef06f8172bc909cd0d6 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:38:34 +0200 Subject: [PATCH 51/77] added new file picker component --- .../public/components/file_picker.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 x-pack/examples/files_example/public/components/file_picker.tsx 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<Props> = ({ onClose, onDone }) => { + return <FilePicker kind={exampleFileKind.id} onClose={onClose} onDone={onDone} />; +}; From 57a0b6588d87f244c0cc79d3bc198ce132227411 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 15:43:53 +0200 Subject: [PATCH 52/77] make file cards a bit wider and text a bit smaller so that it wraps... --- .../components/file_picker/components/file_card.tsx | 9 ++++++++- .../components/file_picker/components/file_grid.tsx | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) 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 e6e63d5af3518..9964552478b8f 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 @@ -75,7 +75,14 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { } description={ <> - <EuiText size="s"> + <EuiText + size="xs" + css={css` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + `} + > <strong> {file.name}.{file.extension} </strong> 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 index c1790869c4bb3..669814b68a3b4 100644 --- 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 @@ -26,7 +26,7 @@ export const FileGrid: FunctionComponent = () => { <div css={css` display: grid; - grid-template-columns: repeat(auto-fill, minmax(calc(${euiTheme.size.xxxxl} * 2.5), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(calc(${euiTheme.size.xxxxl} * 3), 1fr)); gap: ${euiTheme.size.m}; `} > From 2233c93a5fcb3beef43fe530afdf2ad5ddf5550d Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:10:25 +0200 Subject: [PATCH 53/77] fix issue of throwing abort error and prematurely setting request to completed... --- .../file_picker/components/file_card.tsx | 17 ++++++++--- .../file_picker/components/search_field.tsx | 2 ++ .../components/file_picker/file_picker.tsx | 5 ++-- .../file_picker/file_picker_state.test.ts | 6 ++-- .../file_picker/file_picker_state.ts | 29 +++++++++++++++---- 5 files changed, 45 insertions(+), 14 deletions(-) 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 9964552478b8f..8f73767608638 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 @@ -76,20 +76,29 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { description={ <> <EuiText - size="xs" + size="s" css={css` overflow: hidden; white-space: nowrap; text-overflow: ellipsis; `} > - <strong> - {file.name}.{file.extension} - </strong> + <strong>{file.name}</strong> </EuiText> <EuiText color="subdued" size="xs"> {numeral(file.size).format('0[.]0 b')} </EuiText> + {file.extension ? ( + <EuiText + css={css` + text-transform: uppercase; + `} + color="subdued" + size="xs" + > + {file.extension} + </EuiText> + ) : null} </> } hasBorder 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 2239261d2be61..63600c1ac3afe 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 @@ -15,8 +15,10 @@ import { useBehaviorSubject } from '../../use_behavior_subject'; export const SearchField: FunctionComponent = () => { const { state } = useFilePickerContext(); const query = useBehaviorSubject(state.query$); + const hasFiles = useBehaviorSubject(state.hasFiles$); return ( <EuiFieldSearch + disabled={!query && !hasFiles} value={query ?? ''} placeholder={i18nTexts.searchFieldPlaceholder} onChange={(ev) => state.setQuery(ev.target.value)} 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 6a28c01ada049..8e2e2b075a124 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 @@ -55,6 +55,7 @@ const Component: FunctionComponent<Props> = ({ 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$); @@ -67,7 +68,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { <EuiModal className="filesFilePicker filesFilePicker--fixed" maxWidth="75vw" onClose={onClose}> <EuiModalHeader> <Title /> - {hasFiles && <SearchField />} + <SearchField /> </EuiModalHeader> {isLoading ? ( <EuiModalBody> @@ -84,7 +85,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { <EuiModalBody> <ErrorContent error={error as Error} /> </EuiModalBody> - ) : !hasFiles ? ( + ) : !hasFiles && !hasQuery ? ( <EuiModalBody> <UploadFilesPrompt kind={kind} /> </EuiModalBody> 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 index 9c0226bf2b54a..6221133281397 100644 --- 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 @@ -133,21 +133,21 @@ describe('FilePickerState', () => { expect(filesClient.list).toHaveBeenNthCalledWith(2, { abortSignal: expect.any(AbortSignal), kind: 'test', - name: ['a'], + name: ['*a*'], page: 1, perPage: 20, }); expect(filesClient.list).toHaveBeenNthCalledWith(3, { abortSignal: expect.any(AbortSignal), kind: 'test', - name: ['b'], + name: ['*b*'], page: 1, perPage: 20, }); expect(filesClient.list).toHaveBeenNthCalledWith(4, { abortSignal: expect.any(AbortSignal), kind: 'test', - name: ['b'], + name: ['*b*'], page: 3, perPage: 20, }); 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 9535f422c6d0c..f21073aa9e73f 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 @@ -21,6 +21,10 @@ import { 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 @@ -30,6 +34,7 @@ export class FilePickerState { 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 currentPage$ = new BehaviorSubject<number>(0); public readonly totalPages$ = new BehaviorSubject<undefined | number>(undefined); @@ -47,7 +52,14 @@ export class FilePickerState { private readonly kind: string, public readonly pageSize: number ) { - this.subscriptions = []; + this.subscriptions = [ + this.query$ + .pipe( + map((query) => Boolean(query)), + distinctUntilChanged() + ) + .subscribe(this.hasQuery$), + ]; } /** @@ -93,19 +105,23 @@ export class FilePickerState { const abortController = new AbortController(); this.abort = () => { - abortController.abort(); + try { + abortController.abort(); + } catch (e) { + // ignore + } }; const request$ = from( this.client.list({ kind: this.kind, - name: query ? [query] : undefined, + name: query ? [naivelyFuzzify(query)] : undefined, page: page + 1, perPage: this.pageSize, abortSignal: abortController.signal, }) ).pipe( - finalize(() => { + tap(() => { this.isLoading$.next(false); this.abort = undefined; }), @@ -113,7 +129,10 @@ export class FilePickerState { ); request$.subscribe({ - error: (e) => this.loadingError$.next(e), + error: (e: Error) => { + if (e.name === 'AbortError') return; + this.loadingError$.next(e); + }, }); return request$; From e07decca96dab059b393b0bf0a48a17a1fcd87a2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:11:55 +0200 Subject: [PATCH 54/77] remove unused import --- .../files/public/components/file_picker/file_picker_state.ts | 1 - 1 file changed, 1 deletion(-) 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 f21073aa9e73f..49a8e73c37e03 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,6 @@ import { map, tap, from, - finalize, switchMap, Observable, shareReplay, From 3b4b4e24eb3b44fdb8bf3a36efc99c6d51bcd823 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:13:28 +0200 Subject: [PATCH 55/77] replace filter i18n --- .../plugins/files/public/components/file_picker/i18n_texts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 42c33df6e488a..54b6604cbe84d 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 @@ -32,7 +32,7 @@ export const i18nTexts = { values: { nrOfFiles }, }), searchFieldPlaceholder: i18n.translate('xpack.files.filePicker.searchFieldPlaceholder', { - defaultMessage: 'Filter by name', + defaultMessage: 'my-file-*', }), emptyFileGridPrompt: i18n.translate('xpack.files.filePicker.emptyGridPrompt', { defaultMessage: 'No files matched filter', From 1373c525289bfa637f3f926d12b6aff52f0f5510 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:38:29 +0200 Subject: [PATCH 56/77] a bunch of cool changes --- .../file_picker/components/file_card.tsx | 3 +- .../components/file_picker/file_picker.tsx | 39 +++++++++++-------- .../upload_file/upload_file.component.tsx | 8 +++- .../components/upload_file/upload_file.tsx | 7 ++++ 4 files changed, 38 insertions(+), 19 deletions(-) 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 8f73767608638..e2442c48b9c06 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 @@ -11,7 +11,7 @@ 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 { FileJSON } from '../../../../common'; +import { FileImageMetadata, FileJSON } from '../../../../common'; import { Image } from '../../image'; import { isImage } from '../../util'; import { useFilesContext } from '../../context'; @@ -58,6 +58,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { css={css` max-height: ${imageHeight}; `} + meta={file.meta as FileImageMetadata} src={client.getDownloadHref({ id: file.id, fileKind: kind })} /> ) : ( 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 8e2e2b075a124..d1f96a424d73e 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 @@ -64,6 +64,15 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { return () => sub.unsubscribe(); }, [state]); + const renderFooter = () => ( + <EuiModalFooter> + <EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center"> + <Pagination /> + <SelectButton onClick={onDone} /> + </EuiFlexGroup> + </EuiModalFooter> + ); + return ( <EuiModal className="filesFilePicker filesFilePicker--fixed" maxWidth="75vw" onClose={onClose}> <EuiModalHeader> @@ -71,16 +80,19 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { <SearchField /> </EuiModalHeader> {isLoading ? ( - <EuiModalBody> - <div - css={css` - display: grid; - place-items: center; - `} - > - <EuiLoadingSpinner size="xl" /> - </div> - </EuiModalBody> + <> + <EuiModalBody> + <div + css={css` + display: grid; + place-items: center; + `} + > + <EuiLoadingSpinner size="xl" /> + </div> + </EuiModalBody> + {renderFooter()} + </> ) : Boolean(error) ? ( <EuiModalBody> <ErrorContent error={error as Error} /> @@ -96,12 +108,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { <EuiSpacer /> <ClearFilterButton onClick={() => state.setQuery(undefined)} /> </EuiModalBody> - <EuiModalFooter> - <EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center"> - <Pagination /> - <SelectButton onClick={onDone} /> - </EuiFlexGroup> - </EuiModalFooter> + {renderFooter()} </> )} </EuiModal> 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 a1ff4fa318076..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,7 @@ import { useUploadState } from './context'; export interface Props { meta?: unknown; accept?: string; + multiple?: boolean; fullWidth?: boolean; immediate?: boolean; allowClear?: boolean; @@ -34,7 +35,10 @@ export interface Props { const { euiFormMaxWidth, euiButtonHeightSmall } = euiThemeVars; export const UploadFile = React.forwardRef<EuiFilePicker, Props>( - ({ meta, accept, immediate, allowClear = false, initialFilePromptText, fullWidth }, ref) => { + ( + { meta, accept, immediate, allowClear = false, multiple, initialFilePromptText, fullWidth }, + ref + ) => { const uploadState = useUploadState(); const uploading = useBehaviorSubject(uploadState.uploading$); const error = useBehaviorSubject(uploadState.error$); @@ -61,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} 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 4f3e148d96fd3..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 @@ -75,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; } /** @@ -90,6 +95,7 @@ export const UploadFile = <Kind extends string = string>({ fullWidth, allowClear, kind: kindId, + multiple = false, initialPromptText, immediate = false, allowRepeatedUploads = false, @@ -133,6 +139,7 @@ export const UploadFile = <Kind extends string = string>({ allowClear={allowClear} fullWidth={fullWidth} initialFilePromptText={initialPromptText} + multiple={multiple} /> </context.Provider> ); From 54eecae5b03473561a08d643a23e8738029896fb Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:38:45 +0200 Subject: [PATCH 57/77] added export for the file picker component --- .../public/components/file_picker/index.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/index.tsx 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> +); From 9eef858bfcb0f5f349455a6ac065b5b0cbcc7bc4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 16:39:12 +0200 Subject: [PATCH 58/77] updated example app to use multiple file upload --- x-pack/examples/files_example/public/components/app.tsx | 4 +++- x-pack/examples/files_example/public/components/modal.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index 929cf00040efb..0eb2234fba2f5 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -164,7 +164,9 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = }} /> )} - {showFilePickerModal && <MyFilePicker onClose={() => {}} onDone={() => {}} />} + {showFilePickerModal && ( + <MyFilePicker onClose={() => setShowFilePickerModal(false)} onDone={() => {}} /> + )} </> ); }; diff --git a/x-pack/examples/files_example/public/components/modal.tsx b/x-pack/examples/files_example/public/components/modal.tsx index 67b2504c5aaab..d8289257617cf 100644 --- a/x-pack/examples/files_example/public/components/modal.tsx +++ b/x-pack/examples/files_example/public/components/modal.tsx @@ -26,7 +26,12 @@ export const Modal: FunctionComponent<Props> = ({ onDismiss, onUploaded, client </EuiText> </EuiModalHeader> <EuiModalBody> - <UploadFile kind={exampleFileKind.id} onDone={onUploaded} meta={{ custom: 'meta' }} /> + <UploadFile + multiple + kind={exampleFileKind.id} + onDone={onUploaded} + meta={{ custom: 'meta' }} + /> </EuiModalBody> </EuiModal> ); From bb02c64679577c6c4377d0f53fd730ef17c7974c Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 17:01:12 +0200 Subject: [PATCH 59/77] added some comments and made images load eagerly in file picker for now... --- .../file_picker/components/file_card.tsx | 5 ++++ .../files/public/components/image/image.tsx | 28 +++++++++++++++++-- .../components/image/viewport_observer.ts | 5 +++- 3 files changed, 34 insertions(+), 4 deletions(-) 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 e2442c48b9c06..3787df2aa5447 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 @@ -60,6 +60,11 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { `} meta={file.meta as FileImageMetadata} src={client.getDownloadHref({ id: file.id, fileKind: kind })} + // There is an issue where the intersection observer does not fire reliably. + // I'm not sure if this is becuause of the image being in a modal + // The result is that the image does not always get loaded. + // TODO: Investigate this behaviour further + loadImageEagerly /> ) : ( <div diff --git a/x-pack/plugins/files/public/components/image/image.tsx b/x-pack/plugins/files/public/components/image/image.tsx index 915f45c828f66..8c63a3cb7b7e2 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 false + */ + loadImageEagerly?: 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', + loadImageEagerly = false, + ...rest + }, ref ) => { const [isLoaded, setIsLoaded] = useState<boolean>(false); const [blurDelayExpired, setBlurDelayExpired] = useState(false); const { isVisible, ref: observerRef } = useViewportObserver({ onFirstVisible }); + const loadImage = loadImageEagerly ? true : isVisible; + 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, + }); } /** From 217a4f510232c0858b9322b4ecf6e7d2a92eb0d4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 17:11:10 +0200 Subject: [PATCH 60/77] complete ux for examples --- .../examples/files_example/public/components/app.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index 0eb2234fba2f5..09347910f594c 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -165,7 +165,16 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = /> )} {showFilePickerModal && ( - <MyFilePicker onClose={() => setShowFilePickerModal(false)} onDone={() => {}} /> + <MyFilePicker + onClose={() => setShowFilePickerModal(false)} + onDone={(ids) => { + notifications.toasts.addSuccess({ + title: 'Selected files!', + text: 'IDS:' + JSON.stringify(ids, null, 2), + }); + setShowFilePickerModal(false); + }} + /> )} </> ); From 0a7d8a3ab86fee0477075d8cd808de4fdd344fc5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Fri, 14 Oct 2022 18:36:14 +0200 Subject: [PATCH 61/77] only files that are "READY" should be in the file picker --- .../public/components/file_picker/components/upload_files.tsx | 2 +- .../public/components/file_picker/file_picker_state.test.ts | 4 ++++ .../files/public/components/file_picker/file_picker_state.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) 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 index 1e5ea5c79e807..259fb78211887 100644 --- 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 @@ -34,7 +34,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { immediate onDone={(file) => { state.selectFile(file.map(({ id }) => id)); - state.loadFiles(); + state.retry(); }} />, ]} 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 index 6221133281397..79eb5cbfa529d 100644 --- 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 @@ -129,6 +129,7 @@ describe('FilePickerState', () => { name: undefined, page: 1, perPage: 20, + status: ['READY'], }); expect(filesClient.list).toHaveBeenNthCalledWith(2, { abortSignal: expect.any(AbortSignal), @@ -136,6 +137,7 @@ describe('FilePickerState', () => { name: ['*a*'], page: 1, perPage: 20, + status: ['READY'], }); expect(filesClient.list).toHaveBeenNthCalledWith(3, { abortSignal: expect.any(AbortSignal), @@ -143,6 +145,7 @@ describe('FilePickerState', () => { name: ['*b*'], page: 1, perPage: 20, + status: ['READY'], }); expect(filesClient.list).toHaveBeenNthCalledWith(4, { abortSignal: expect.any(AbortSignal), @@ -150,6 +153,7 @@ describe('FilePickerState', () => { name: ['*b*'], page: 3, perPage: 20, + status: ['READY'], }); }); }); 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 49a8e73c37e03..bec727699de1b 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 @@ -116,6 +116,7 @@ export class FilePickerState { kind: this.kind, name: query ? [naivelyFuzzify(query)] : undefined, page: page + 1, + status: ['READY'], perPage: this.pageSize, abortSignal: abortController.signal, }) From 0d1d530b78a3d65e1b2cf8e6da55f5e2b0494c04 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 18 Oct 2022 13:59:37 +0200 Subject: [PATCH 62/77] set loading to false if error --- .../files/public/components/file_picker/file_picker_state.ts | 1 + 1 file changed, 1 insertion(+) 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 bec727699de1b..d837bfc78b6fb 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 @@ -131,6 +131,7 @@ export class FilePickerState { request$.subscribe({ error: (e: Error) => { if (e.name === 'AbortError') return; + this.isLoading$.next(false); this.loadingError$.next(e); }, }); From e742e2b3175ab797505ac1d2a8ed4c8517526e08 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 18 Oct 2022 14:48:18 +0200 Subject: [PATCH 63/77] install data-test-subj everywhere! --- .../components/file_picker/components/error_content.tsx | 1 + .../components/file_picker/components/file_grid.tsx | 5 +++-- .../components/file_picker/components/search_field.tsx | 1 + .../components/file_picker/components/select_button.tsx | 6 +++++- .../components/file_picker/components/upload_files.tsx | 1 + .../files/public/components/file_picker/file_picker.tsx | 9 +++++++-- 6 files changed, 18 insertions(+), 5 deletions(-) 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 index 32ecf23037cc2..c2925c793fe63 100644 --- 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 @@ -21,6 +21,7 @@ export const ErrorContent: FunctionComponent<Props> = ({ error }) => { const isLoading = useBehaviorSubject(state.isLoading$); return ( <EuiEmptyPrompt + data-test-subj="errorPrompt" iconType="alert" iconColor="danger" titleSize="xs" 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 index 669814b68a3b4..2f2a9722d55b7 100644 --- 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 @@ -24,14 +24,15 @@ export const FileGrid: FunctionComponent = () => { } return ( <div + data-test-subj="fileGrid" css={css` display: grid; grid-template-columns: repeat(auto-fill, minmax(calc(${euiTheme.size.xxxxl} * 3), 1fr)); gap: ${euiTheme.size.m}; `} > - {files.map((file) => ( - <FileCard file={file} /> + {files.map((file, idx) => ( + <FileCard key={idx} file={file} /> ))} </div> ); 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 63600c1ac3afe..c000e198b2a06 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 @@ -18,6 +18,7 @@ export const SearchField: FunctionComponent = () => { const hasFiles = useBehaviorSubject(state.hasFiles$); return ( <EuiFieldSearch + data-test-subj="searchField" disabled={!query && !hasFiles} value={query ?? ''} placeholder={i18nTexts.searchFieldPlaceholder} 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 index 485e3ae527d05..0197e402e6db6 100644 --- 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 @@ -20,7 +20,11 @@ export const SelectButton: FunctionComponent<Props> = ({ onClick }) => { const { state } = useFilePickerContext(); const selectedFiles = useBehaviorSubject(state.selectedFileIds$); return ( - <EuiButton disabled={!state.hasFilesSelected()} onClick={() => onClick(selectedFiles)}> + <EuiButton + data-test-subj="selectButton" + disabled={!state.hasFilesSelected()} + onClick={() => onClick(selectedFiles)} + > {selectedFiles.length > 1 ? i18nTexts.selectFilesLabel(selectedFiles.length) : i18nTexts.selectFileLabel} 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 index 259fb78211887..143d20fd63ec0 100644 --- 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 @@ -20,6 +20,7 @@ export const UploadFilesPrompt: FunctionComponent<Props> = ({ kind }) => { const { state } = useFilePickerContext(); return ( <EuiEmptyPrompt + data-test-subj="emptyPrompt" title={<h3>{i18nTexts.emptyStatePrompt}</h3>} body={ <EuiText color="subdued" size="s"> 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 d1f96a424d73e..25903f30c0cf1 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 @@ -74,7 +74,12 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { ); return ( - <EuiModal className="filesFilePicker filesFilePicker--fixed" maxWidth="75vw" onClose={onClose}> + <EuiModal + data-test-subj="filePickerModal" + className="filesFilePicker filesFilePicker--fixed" + maxWidth="75vw" + onClose={onClose} + > <EuiModalHeader> <Title /> <SearchField /> @@ -88,7 +93,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { place-items: center; `} > - <EuiLoadingSpinner size="xl" /> + <EuiLoadingSpinner data-test-subj="loadingSpinner" size="xl" /> </div> </EuiModalBody> {renderFooter()} From 25b04ac98015b8d4baa70f6353f616cb2532a62f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 18 Oct 2022 14:48:38 +0200 Subject: [PATCH 64/77] added some react component tests --- .../file_picker/file_picker.test.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 x-pack/plugins/files/public/components/file_picker/file_picker.test.tsx 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..774e9883643a0 --- /dev/null +++ b/x-pack/plugins/files/public/components/file_picker/file_picker.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { promisify } from 'util'; +import React from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { registerTestBed } from '@kbn/test-jest-helpers'; + +const pImmediate = promisify(global.setImmediate); + +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<typeof createMockFilesClient>; + let onDone: jest.Mock; + let onClose: jest.Mock; + + async function initTestBed(props?: Partial<Props>) { + const createTestBed = registerTestBed((p: Props) => ( + <FilesContext client={client}> + <FilePicker {...p} /> + </FilesContext> + )); + + 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']); + }); +}); From 3276146fbe4583f317055dfafbdb31edba037e87 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 18 Oct 2022 15:33:25 +0200 Subject: [PATCH 65/77] remove unused import --- .../files/public/components/file_picker/file_picker.test.tsx | 3 --- 1 file changed, 3 deletions(-) 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 774e9883643a0..14b621050a0ef 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 @@ -4,14 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { promisify } from 'util'; import React from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { act } from 'react-dom/test-utils'; import { registerTestBed } from '@kbn/test-jest-helpers'; -const pImmediate = promisify(global.setImmediate); - import { createMockFilesClient } from '../../mocks'; import { FilesContext } from '../context'; import { FilePicker, Props } from './file_picker'; From d7b86c17367b1e86597f97a91d031f0d71b97160 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 18 Oct 2022 15:37:21 +0200 Subject: [PATCH 66/77] fix storybook case --- .../public/components/file_picker/file_picker.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b4b59869a90af..ee6fe36f33ad0 100644 --- 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 @@ -129,14 +129,14 @@ export const BasicManyMany = Template.bind({}); BasicManyMany.decorators = [ (Story) => { const array = new Array(102); - array.fill(createFileJSON()); + array.fill(null); return ( <FilesContext client={ { getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, list: async (): Promise<FilesClientResponses['list']> => ({ - files: array, + files: array.map((_, idx) => createFileJSON({ id: String(idx) })), total: array.length, }), } as unknown as FilesClient From eca2bd307ee879c066366c83e82f849abae1b4dc Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 13:37:56 +0200 Subject: [PATCH 67/77] fix up where the files example plugin is listed, moved it to the developer examples area --- x-pack/examples/files_example/common/index.ts | 4 ++-- x-pack/examples/files_example/kibana.json | 2 +- x-pack/examples/files_example/public/imports.ts | 2 ++ x-pack/examples/files_example/public/plugin.ts | 13 ++++++++++++- x-pack/examples/files_example/public/types.ts | 9 ++++++++- x-pack/examples/files_example/tsconfig.json | 9 +++------ 6 files changed, 28 insertions(+), 11 deletions(-) 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/imports.ts b/x-pack/examples/files_example/public/imports.ts index bd58dc021394d..a60d9cb4a6a36 100644 --- a/x-pack/examples/files_example/public/imports.ts +++ b/x-pack/examples/files_example/public/imports.ts @@ -15,3 +15,5 @@ export { 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<unknown, unknown, FilesExamplePluginsSetup, FilesExamplePluginsStart> { - public setup(core: CoreSetup<FilesExamplePluginsStart>, { files }: FilesExamplePluginsSetup) { + public setup( + core: CoreSetup<FilesExamplePluginsStart>, + { 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" } ] } From b3b52d5898927bf56dd818f849aec5f2b88c6033 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:06:45 +0200 Subject: [PATCH 68/77] fix potential flashing of loader by debouncing --- .../file_picker/components/search_field.tsx | 2 ++ .../components/file_picker/file_picker_state.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) 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 c000e198b2a06..0235b03dd3fc1 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 @@ -15,11 +15,13 @@ 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 ( <EuiFieldSearch data-test-subj="searchField" disabled={!query && !hasFiles} + isLoading={isLoading} value={query ?? ''} placeholder={i18nTexts.searchFieldPlaceholder} onChange={(ev) => state.setQuery(ev.target.value)} 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 d837bfc78b6fb..dc1f479df49f6 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 @@ -45,6 +45,7 @@ export class FilePickerState { 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, @@ -54,10 +55,14 @@ export class FilePickerState { 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$), ]; } @@ -88,6 +93,10 @@ export class FilePickerState { 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(); @@ -99,7 +108,7 @@ export class FilePickerState { query: undefined | string ): Observable<{ files: FileJSON[]; total: number }> => { if (this.abort) this.abort(); - this.isLoading$.next(true); + this.setIsLoading(true); this.loadingError$.next(undefined); const abortController = new AbortController(); @@ -122,7 +131,7 @@ export class FilePickerState { }) ).pipe( tap(() => { - this.isLoading$.next(false); + this.setIsLoading(false); this.abort = undefined; }), shareReplay() @@ -131,7 +140,7 @@ export class FilePickerState { request$.subscribe({ error: (e: Error) => { if (e.name === 'AbortError') return; - this.isLoading$.next(false); + this.setIsLoading(false); this.loadingError$.next(e); }, }); From 28dbef09a76a6c9a5d011f5f41dd45839d5042f9 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:13:47 +0200 Subject: [PATCH 69/77] do not create new observable on every render --- .../components/file_picker/components/clear_filter_button.tsx | 3 +-- .../files/public/components/file_picker/file_picker_state.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) 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 451c45c01a3e8..485e749a59cb0 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 @@ -6,7 +6,6 @@ */ import React from 'react'; import type { FunctionComponent } from 'react'; -import { debounceTime } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { EuiLink } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -18,7 +17,7 @@ interface Props { export const ClearFilterButton: FunctionComponent<Props> = ({ onClick }) => { const { state } = useFilePickerContext(); - const query = useObservable(state.query$.pipe(debounceTime(100))); + const query = useObservable(state.queryDebounced$); if (!query) { return null; } 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 dc1f479df49f6..3ca6ee9ffca99 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 @@ -35,6 +35,7 @@ export class FilePickerState { 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); From 786485eaa8965d55241148e35c02d9bbf5f78b53 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:15:05 +0200 Subject: [PATCH 70/77] i18n --- .../components/file_picker/components/clear_filter_button.tsx | 4 +++- .../plugins/files/public/components/file_picker/i18n_texts.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 485e749a59cb0..14356b9b02bd4 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 @@ -11,6 +11,8 @@ import { EuiLink } from '@elastic/eui'; import { css } from '@emotion/react'; import { useFilePickerContext } from '../context'; +import { i18nTexts } from '../i18n_texts'; + interface Props { onClick: () => void; } @@ -28,7 +30,7 @@ export const ClearFilterButton: FunctionComponent<Props> = ({ onClick }) => { place-items: center; `} > - <EuiLink onClick={onClick}>Clear filter</EuiLink> + <EuiLink onClick={onClick}>{i18nTexts.clearFilterButton}</EuiLink> </div> ); }; 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 54b6604cbe84d..2670ecd71b084 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 @@ -40,4 +40,7 @@ export const i18nTexts = { loadMoreButtonLabel: i18n.translate('xpack.files.filePicker.loadMoreButtonLabel', { defaultMessage: 'Load more', }), + clearFilterButton: i18n.translate('xpack.files.filePicker.clearFilterButtonLabel', { + defaultMessage: 'Clear filter', + }), }; From fe23fdea4d3eb03e8591c5ff2ddf8ece53066b45 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:18:13 +0200 Subject: [PATCH 71/77] have only filepicker ctx used in filepicker components --- .../components/file_picker/components/file_card.tsx | 4 +--- .../files/public/components/file_picker/context.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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 3787df2aa5447..43660f59b3c6c 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 @@ -14,7 +14,6 @@ import { css } from '@emotion/react'; import { FileImageMetadata, FileJSON } from '../../../../common'; import { Image } from '../../image'; import { isImage } from '../../util'; -import { useFilesContext } from '../../context'; import { useFilePickerContext } from '../context'; import './file_card.scss'; @@ -24,8 +23,7 @@ interface Props { } export const FileCard: FunctionComponent<Props> = ({ file }) => { - const { client } = useFilesContext(); - const { kind, state } = useFilePickerContext(); + const { kind, state, client } = useFilePickerContext(); const { euiTheme } = useEuiTheme(); const displayImage = isImage({ type: file.mimeType }); diff --git a/x-pack/plugins/files/public/components/file_picker/context.tsx b/x-pack/plugins/files/public/components/file_picker/context.tsx index 045e405665a47..67e745b745829 100644 --- a/x-pack/plugins/files/public/components/file_picker/context.tsx +++ b/x-pack/plugins/files/public/components/file_picker/context.tsx @@ -7,10 +7,10 @@ import React, { createContext, useContext, useMemo, useEffect } from 'react'; import type { FunctionComponent } from 'react'; -import { useFilesContext } from '../context'; +import { useFilesContext, FilesContextValue } from '../context'; import { FilePickerState, createFilePickerState } from './file_picker_state'; -interface FilePickerContextValue { +interface FilePickerContextValue extends FilesContextValue { state: FilePickerState; kind: string; } @@ -28,13 +28,18 @@ export const FilePickerContext: FunctionComponent<FilePickerContextProps> = ({ pageSize, children, }) => { - const { client } = useFilesContext(); + const filesContext = useFilesContext(); + const { client } = filesContext; const state = useMemo( () => createFilePickerState({ pageSize, client, kind }), [pageSize, client, kind] ); useEffect(() => state.dispose, [state]); - return <FilePickerCtx.Provider value={{ state, kind }}>{children}</FilePickerCtx.Provider>; + return ( + <FilePickerCtx.Provider value={{ state, kind, ...filesContext }}> + {children} + </FilePickerCtx.Provider> + ); }; export const useFilePickerContext = (): FilePickerContextValue => { From 94e8caa1d25c22d9d6706fd2cc729e379493bcba Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:24:33 +0200 Subject: [PATCH 72/77] refactor loadImageEagerly -> lazy --- .../components/file_picker/components/file_card.tsx | 2 +- x-pack/plugins/files/public/components/image/image.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 43660f59b3c6c..936edfb085fe2 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 @@ -62,7 +62,7 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { // I'm not sure if this is becuause of the image being in a modal // The result is that the image does not always get loaded. // TODO: Investigate this behaviour further - loadImageEagerly + lazy={false} /> ) : ( <div diff --git a/x-pack/plugins/files/public/components/image/image.tsx b/x-pack/plugins/files/public/components/image/image.tsx index 8c63a3cb7b7e2..b83739d180c94 100644 --- a/x-pack/plugins/files/public/components/image/image.tsx +++ b/x-pack/plugins/files/public/components/image/image.tsx @@ -38,9 +38,9 @@ export interface Props extends ImgHTMLAttributes<HTMLImageElement> { * This setting overrides this behavior and loads an image as soon as the * component mounts. * - * @default false + * @default true */ - loadImageEagerly?: boolean; + lazy?: boolean; } /** @@ -64,7 +64,7 @@ export const Image = React.forwardRef<HTMLImageElement, Props>( meta, wrapperProps, size = 'original', - loadImageEagerly = false, + lazy = true, ...rest }, ref @@ -73,7 +73,7 @@ export const Image = React.forwardRef<HTMLImageElement, Props>( const [blurDelayExpired, setBlurDelayExpired] = useState(false); const { isVisible, ref: observerRef } = useViewportObserver({ onFirstVisible }); - const loadImage = loadImageEagerly ? true : isVisible; + const loadImage = lazy ? isVisible : true; useEffect(() => { let unmounted = false; From b05f2d9740540439836e70ae29c564757773c430 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:26:29 +0200 Subject: [PATCH 73/77] useObservable instead of useEffect --- .../files/public/components/file_picker/file_picker.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 25903f30c0cf1..981ce63ab9dd7 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 @@ -5,8 +5,9 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React from 'react'; import type { FunctionComponent } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import { EuiModal, EuiModalBody, @@ -59,10 +60,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { const isLoading = useBehaviorSubject(state.isLoading$); const error = useBehaviorSubject(state.loadingError$); - useEffect(() => { - const sub = state.files$.subscribe(); - return () => sub.unsubscribe(); - }, [state]); + useObservable(state.files$); const renderFooter = () => ( <EuiModalFooter> From ba7f6bd82df2593a17ed303d516b4eb3a847cc33 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:33:25 +0200 Subject: [PATCH 74/77] factor modal footer to own component and remove css util --- .../file_picker/components/modal_footer.tsx | 28 +++++++++++++++++++ .../file_picker/components/select_button.tsx | 2 +- .../components/file_picker/file_picker.tsx | 23 +++------------ 3 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/files/public/components/file_picker/components/modal_footer.tsx 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<Props> = ({ onDone }) => { + return ( + <EuiModalFooter> + <EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center"> + <Pagination /> + <SelectButton onClick={onDone} /> + </EuiFlexGroup> + </EuiModalFooter> + ); +}; 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 index 0197e402e6db6..ac5e241c01d53 100644 --- 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 @@ -12,7 +12,7 @@ import { useBehaviorSubject } from '../../use_behavior_subject'; import { useFilePickerContext } from '../context'; import { i18nTexts } from '../i18n_texts'; -interface Props { +export interface Props { onClick: (selectedFiles: string[]) => void; } 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 981ce63ab9dd7..72920b72a865d 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 @@ -12,13 +12,11 @@ import { EuiModal, EuiModalBody, EuiModalHeader, - EuiModalFooter, EuiLoadingSpinner, EuiSpacer, EuiFlexGroup, } from '@elastic/eui'; -import { css } from '@emotion/react'; import { useBehaviorSubject } from '../use_behavior_subject'; import { useFilePickerContext, FilePickerContext } from './context'; @@ -27,8 +25,7 @@ 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 { SelectButton } from './components/select_button'; -import { Pagination } from './components/pagination'; +import { ModalFooter } from './components/modal_footer'; import './file_picker.scss'; import { ClearFilterButton } from './components/clear_filter_button'; @@ -62,14 +59,7 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { useObservable(state.files$); - const renderFooter = () => ( - <EuiModalFooter> - <EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center"> - <Pagination /> - <SelectButton onClick={onDone} /> - </EuiFlexGroup> - </EuiModalFooter> - ); + const renderFooter = () => <ModalFooter onDone={onDone} />; return ( <EuiModal @@ -85,14 +75,9 @@ const Component: FunctionComponent<Props> = ({ onClose, onDone }) => { {isLoading ? ( <> <EuiModalBody> - <div - css={css` - display: grid; - place-items: center; - `} - > + <EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="none"> <EuiLoadingSpinner data-test-subj="loadingSpinner" size="xl" /> - </div> + </EuiFlexGroup> </EuiModalBody> {renderFooter()} </> From 7b83fe18b6f83836848bc39e8f5185973a201cb6 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Wed, 19 Oct 2022 14:53:18 +0200 Subject: [PATCH 75/77] use the middle dot luke --- .../file_picker/components/file_card.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 936edfb085fe2..4c290b1b114e7 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 @@ -91,18 +91,19 @@ export const FileCard: FunctionComponent<Props> = ({ file }) => { </EuiText> <EuiText color="subdued" size="xs"> {numeral(file.size).format('0[.]0 b')} + {file.extension && ( + <> +   ·   + <span + css={css` + text-transform: uppercase; + `} + > + {file.extension} + </span> + </> + )} </EuiText> - {file.extension ? ( - <EuiText - css={css` - text-transform: uppercase; - `} - color="subdued" - size="xs" - > - {file.extension} - </EuiText> - ) : null} </> } hasBorder From 784cf1c6bd12a8da12dfafcc4b537fe7fca2e029 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 20 Oct 2022 16:31:03 +0200 Subject: [PATCH 76/77] copy update in files example app --- x-pack/examples/files_example/public/components/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/examples/files_example/public/components/app.tsx b/x-pack/examples/files_example/public/components/app.tsx index 09347910f594c..d3dfbdeb71874 100644 --- a/x-pack/examples/files_example/public/components/app.tsx +++ b/x-pack/examples/files_example/public/components/app.tsx @@ -51,7 +51,7 @@ export const FilesExampleApp = ({ files, notifications }: FilesExampleAppDeps) = isDisabled={isLoading || isDeletingFile} iconType="eye" > - Pick a file + Select a file </EuiButton>, <EuiButton onClick={() => setShowUploadModal(true)} From 151ead5484e9de0a2f4c8abba2310885d42f47f5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Thu, 20 Oct 2022 16:49:53 +0200 Subject: [PATCH 77/77] added filter story --- .../file_picker/file_picker.stories.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 index ee6fe36f33ad0..9d40b112b4060 100644 --- 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 @@ -169,3 +169,30 @@ ErrorLoading.decorators = [ ); }, ]; + +export const TryFilter = Template.bind({}); +TryFilter.decorators = [ + (Story) => { + const array = { files: [createFileJSON()], total: 1 }; + return ( + <> + <h2>Try entering a filter!</h2> + <FilesContext + client={ + { + getDownloadHref: () => `data:image/png;base64,${base64dLogo}`, + list: async ({ name }: { name: string[] }) => { + if (name) { + return { files: [], total: 0 }; + } + return array; + }, + } as unknown as FilesClient + } + > + <Story /> + </FilesContext> + </> + ); + }, +];