[Files] Upload file (#140493)

* wip file upload component

* early version of upload component, ui only

* compressed buttons

* retry button and restructure some more stuff about default args for the stories

* added first iteration of upload state with tests

* do not self-unsubscribe

* remove unused import

* added simple state store observable and ability to abort uploads

* rename file_upload => file_upload_ui

* rename fileupload -> uploadfile and created new upload_files.ts stateful component, wip

* move file kinds registry to common

* hook up files registry to public side, also delete server side implementation

* implemented upload file ui, changed files$ to be a list of objects, not an array of observables

* did a bunch of stuff hooking up state to component

* added public side mock for filesclient

* rather use existing state to derive values, some other stuff

* added comment

* added files context

* throw first error

* check max file size and added abort state tets

* use i18n

* check mime type too and handle retry state correctly

* handle immediate upload

* update error message

* actually give files back after done

* upload files => upload file and move some stuff around

* map instead of tap

* use form row for showing the error message

* minor refactor

* export everything

* move some files around

* added some react jest tests too

* actually add the test file (previous commit was for a fix

* added processing of file names

* try fix import path

* remove stories for UI component

* type lints

* added i18n.json

* reverse direction again

* kibana utils bundle added

* type lint

* remove unnecessary variable

* updated where route registration happens

* remove _

* removed an option (compressed) also enabled repeated uploads and updated how the error message is laid out

* put upload file component behind lazy laoding

* added export of component back in

* typo

* do not show messages while uploading

* added a test case for the display of error messages

* remove unused import

* expand comment

* use the correct file name parser!

* updated story name and how long error message is generated

* rename inner render method

* fix types

* upload_file_ui -> upload_file.component

* address a11y violation and use inline css only

* updated copy per feedback

* refactor state class per feedback

* refactor to use context per vadims recommendation

* added some more tests

* added a comment and allowed passing through of kind type

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* removed props from .component since we now have context

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2022-09-21 13:47:18 +02:00 committed by GitHub
parent 21d01719eb
commit f2bb8974f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1528 additions and 50 deletions

View file

@ -18,6 +18,7 @@
"xpack.endpoint": "plugins/endpoint",
"xpack.enterpriseSearch": "plugins/enterprise_search",
"xpack.features": "plugins/features",
"xpack.files": "plugins/files",
"xpack.dataVisualizer": "plugins/data_visualizer",
"xpack.fileUpload": "plugins/file_upload",
"xpack.globalSearch": ["plugins/global_search"],
@ -38,10 +39,7 @@
"xpack.logstash": ["plugins/logstash"],
"xpack.main": "legacy/plugins/xpack_main",
"xpack.maps": ["plugins/maps"],
"xpack.aiops": [
"packages/ml/aiops_components",
"plugins/aiops"
],
"xpack.aiops": ["packages/ml/aiops_components", "plugins/aiops"],
"xpack.ml": ["plugins/ml"],
"xpack.monitoring": ["plugins/monitoring"],
"xpack.osquery": ["plugins/osquery"],

View file

@ -6,10 +6,7 @@
*/
import { createGetterSetter } from '@kbn/kibana-utils-plugin/common';
import assert from 'assert';
import { FileKind } from '../../common';
import { registerFileKindRoutes } from '../routes/file_kind';
import { FilesRouter } from '../routes/types';
import { FileKind } from '..';
export interface FileKindsRegistry {
/**
@ -32,7 +29,7 @@ export interface FileKindsRegistry {
* @internal
*/
export class FileKindsRegistryImpl implements FileKindsRegistry {
constructor(private readonly router: FilesRouter) {}
constructor(private readonly onRegister?: (fileKind: FileKind) => void) {}
private readonly fileKinds = new Map<string, FileKind>();
@ -48,7 +45,7 @@ export class FileKindsRegistryImpl implements FileKindsRegistry {
}
this.fileKinds.set(fileKind.id, fileKind);
registerFileKindRoutes(this.router, fileKind);
this.onRegister?.(fileKind);
}
get(id: string): FileKind {

View file

@ -10,5 +10,6 @@
"server": true,
"ui": true,
"requiredPlugins": [],
"requiredBundles": ["kibanaUtils"],
"optionalPlugins": ["security", "usageCollection"]
}

View file

@ -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, { createContext, useContext, type FunctionComponent } from 'react';
import { FileKindsRegistry, getFileKindsRegistry } from '../../common/file_kinds_registry';
export interface FilesContextValue {
registry: FileKindsRegistry;
}
const FilesContextObject = createContext<FilesContextValue>(null as unknown as FilesContextValue);
export const useFilesContext = () => {
const ctx = useContext(FilesContextObject);
if (!ctx) {
throw new Error('FilesContext is not found!');
}
return ctx;
};
export const FilesContext: FunctionComponent = ({ children }) => {
return (
<FilesContextObject.Provider
value={{
registry: getFileKindsRegistry(),
}}
>
{children}
</FilesContextObject.Provider>
);
};

View file

@ -5,5 +5,6 @@
* 2.0.
*/
export { Image } from './image';
export type { ImageProps } from './image';
export { Image, type ImageProps } from './image';
export { UploadFile, type UploadFileProps } from './upload_file';
export { FilesContext } from './context';

View file

@ -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 { EuiButtonEmpty } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { useBehaviorSubject } from '../../use_behavior_subject';
import { useUploadState } from '../context';
import { i18nTexts } from '../i18n_texts';
interface Props {
onClick: () => void;
}
export const CancelButton: FunctionComponent<Props> = ({ onClick }) => {
const uploadState = useUploadState();
const uploading = useBehaviorSubject(uploadState.uploading$);
return (
<EuiButtonEmpty
key="cancelButton"
size="s"
data-test-subj="cancelButton"
disabled={!uploading}
onClick={onClick}
color="danger"
>
{i18nTexts.cancel}
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,23 @@
/*
* 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 { EuiButtonEmpty } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { i18nTexts } from '../i18n_texts';
interface Props {
onClick: () => void;
}
export const ClearButton: FunctionComponent<Props> = ({ onClick }) => {
return (
<EuiButtonEmpty size="s" data-test-subj="clearButton" onClick={onClick} color="primary">
{i18nTexts.clear}
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,60 @@
/*
* 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, EuiIcon, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import type { FunctionComponent } from 'react';
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { euiThemeVars } from '@kbn/ui-theme';
import { useBehaviorSubject } from '../../use_behavior_subject';
import { useUploadState } from '../context';
import { i18nTexts } from '../i18n_texts';
import { UploadButton } from './upload_button';
import { RetryButton } from './retry_button';
import { CancelButton } from './cancel_button';
const { euiButtonHeightSmall } = euiThemeVars;
interface Props {
onCancel: () => void;
onUpload: () => void;
immediate?: boolean;
}
export const ControlButton: FunctionComponent<Props> = ({ onCancel, onUpload, immediate }) => {
const uploadState = useUploadState();
const {
euiTheme: { size },
} = useEuiTheme();
const uploading = useBehaviorSubject(uploadState.uploading$);
const files = useObservable(uploadState.files$, []);
const done = useObservable(uploadState.done$);
const retry = Boolean(files.some((f) => f.status === 'upload_failed'));
if (uploading) return <CancelButton onClick={onCancel} />;
if (retry) return <RetryButton onClick={onUpload} />;
if (!done && !immediate) return <UploadButton onClick={onUpload} />;
if (done) {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiIcon
css={css`
margin-inline: ${size.m};
height: ${euiButtonHeightSmall};
`}
data-test-subj="uploadSuccessIcon"
type="checkInCircleFilled"
color="success"
aria-label={i18nTexts.uploadDone}
/>
</EuiFlexGroup>
);
}
return null;
};

View file

@ -0,0 +1,9 @@
/*
* 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 { ControlButton } from './control_button';
export { ClearButton } from './clear_button';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { useBehaviorSubject } from '../../use_behavior_subject';
import { useUploadState } from '../context';
import { i18nTexts } from '../i18n_texts';
interface Props {
onClick: () => void;
}
export const RetryButton: FunctionComponent<Props> = ({ onClick }) => {
const uploadState = useUploadState();
const uploading = useBehaviorSubject(uploadState.uploading$);
return (
<EuiButtonEmpty
key="retryButton"
size="s"
data-test-subj="retryButton"
disabled={uploading}
onClick={onClick}
>
{i18nTexts.retry}
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { i18nTexts } from '../i18n_texts';
import { useUploadState } from '../context';
import { useBehaviorSubject } from '../../use_behavior_subject';
interface Props {
onClick: () => void;
}
export const UploadButton: FunctionComponent<Props> = ({ onClick }) => {
const uploadState = useUploadState();
const uploading = useBehaviorSubject(uploadState.uploading$);
const error = useBehaviorSubject(uploadState.error$);
const files = useObservable(uploadState.files$, []);
return (
<EuiButton
key="uploadButton"
disabled={Boolean(!files.length || uploading || error)}
onClick={onClick}
size="s"
data-test-subj="uploadButton"
>
{uploading ? i18nTexts.uploading : i18nTexts.upload}
</EuiButton>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 { UploadState } from './upload_state';
export const context = React.createContext<UploadState | null>(null);
export const useUploadState = () => React.useContext(context)!;

View file

@ -0,0 +1,38 @@
/*
* 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 = {
defaultPickerLabel: i18n.translate('xpack.files.uploadFile.defaultFilePickerLabel', {
defaultMessage: 'Upload a file',
}),
upload: i18n.translate('xpack.files.uploadFile.uploadButtonLabel', {
defaultMessage: 'Upload',
}),
uploading: i18n.translate('xpack.files.uploadFile.uploadingButtonLabel', {
defaultMessage: 'Uploading',
}),
retry: i18n.translate('xpack.files.uploadFile.retryButtonLabel', {
defaultMessage: 'Retry',
}),
clear: i18n.translate('xpack.files.uploadFile.clearButtonLabel', {
defaultMessage: 'Clear',
}),
cancel: i18n.translate('xpack.files.uploadFile.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
uploadDone: i18n.translate('xpack.files.uploadFile.uploadDoneToolTipContent', {
defaultMessage: 'Your file was successfully uploaded!',
}),
fileTooLarge: (expectedSize: string) =>
i18n.translate('xpack.files.uploadFile.fileTooLargeErrorMessage', {
defaultMessage:
'File is too large. Maximum size is {expectedSize, plural, one {# byte} other {# bytes} }.',
values: { expectedSize },
}),
};

View file

@ -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 './upload_file';
export type { Props as UploadFileProps };
const UploadFileContainer = lazy(() => import('./upload_file'));
export const UploadFile = (props: Props) => (
<Suspense fallback={<EuiLoadingSpinner size="xl" />}>
<UploadFileContainer {...props} />
</Suspense>
);

View file

@ -0,0 +1,114 @@
/*
* 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 {
EuiText,
EuiSpacer,
EuiFlexItem,
EuiFlexGroup,
EuiFilePicker,
useGeneratedHtmlId,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import useObservable from 'react-use/lib/useObservable';
import { useBehaviorSubject } from '../use_behavior_subject';
import { i18nTexts } from './i18n_texts';
import { ControlButton, ClearButton } from './components';
import { useUploadState } from './context';
export interface Props {
meta?: unknown;
accept?: string;
immediate?: boolean;
allowClear?: boolean;
initialFilePromptText?: string;
}
const { euiFormMaxWidth, euiButtonHeightSmall } = euiThemeVars;
export const UploadFile = React.forwardRef<EuiFilePicker, Props>(
({ meta, accept, immediate, allowClear = false, initialFilePromptText }, ref) => {
const uploadState = useUploadState();
const uploading = useBehaviorSubject(uploadState.uploading$);
const error = useBehaviorSubject(uploadState.error$);
const done = useObservable(uploadState.done$);
const isInvalid = Boolean(error);
const errorMessage = error?.message;
const id = useGeneratedHtmlId({ prefix: 'filesUploadFile' });
const errorId = `${id}_error`;
return (
<div
data-test-subj="filesUploadFile"
css={css`
max-width: ${euiFormMaxWidth};
`}
>
<EuiFilePicker
aria-label={i18nTexts.defaultPickerLabel}
id={id}
ref={ref}
onChange={(fs) => {
uploadState.setFiles(Array.from(fs ?? []));
if (immediate) uploadState.upload(meta);
}}
multiple={false}
initialPromptText={initialFilePromptText}
isLoading={uploading}
isInvalid={isInvalid}
accept={accept}
disabled={Boolean(done?.length || uploading)}
aria-describedby={errorMessage ? errorId : undefined}
/>
<EuiSpacer size="s" />
<EuiFlexGroup
justifyContent="flexStart"
alignItems="flexStart"
direction="rowReverse"
gutterSize="m"
>
<EuiFlexItem grow={false}>
<ControlButton
immediate={immediate}
onCancel={uploadState.abort}
onUpload={() => uploadState.upload(meta)}
/>
</EuiFlexItem>
{Boolean(!done && !uploading && errorMessage) && (
<EuiFlexItem>
<EuiText
data-test-subj="error"
css={css`
display: flex;
align-items: center;
min-height: ${euiButtonHeightSmall};
`}
size="s"
color="danger"
>
<span id={errorId}>{errorMessage}</span>
</EuiText>
</EuiFlexItem>
)}
{done?.length && allowClear && (
<>
<EuiFlexItem /> {/* Occupy middle space */}
<EuiFlexItem grow={false}>
<ClearButton onClick={uploadState.clear} />
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</div>
);
}
);

View file

@ -0,0 +1,141 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
FileKindsRegistryImpl,
setFileKindsRegistry,
getFileKindsRegistry,
} from '../../../common/file_kinds_registry';
import { FilesClient } from '../../types';
import { FilesContext } from '../context';
import { UploadFile, Props } from './upload_file';
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
const kind = 'test';
const defaultArgs: Props = {
kind,
onDone: action('onDone'),
onError: action('onError'),
client: {
create: async () => ({ file: { id: 'test' } }),
upload: () => sleep(1000),
} as unknown as FilesClient,
};
export default {
title: 'stateful/UploadFile',
component: UploadFile,
args: defaultArgs,
};
setFileKindsRegistry(new FileKindsRegistryImpl());
getFileKindsRegistry().register({
id: kind,
http: {},
allowedMimeTypes: ['*'],
});
const miniFile = 'miniFile';
getFileKindsRegistry().register({
id: miniFile,
http: {},
maxSizeBytes: 1,
allowedMimeTypes: ['*'],
});
const zipOnly = 'zipOnly';
getFileKindsRegistry().register({
id: zipOnly,
http: {},
allowedMimeTypes: ['application/zip'],
});
const Template: ComponentStory<typeof UploadFile> = (props: Props) => (
<FilesContext>
<UploadFile {...props} />
</FilesContext>
);
export const Basic = Template.bind({});
export const AllowRepeatedUploads = Template.bind({});
AllowRepeatedUploads.args = {
allowRepeatedUploads: true,
};
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,
};
export const Abort = Template.bind({});
Abort.args = {
client: {
create: async () => ({ file: { id: 'test' } }),
upload: async () => {
await sleep(60000);
},
delete: async () => {},
} as unknown as FilesClient,
};
export const MaxSize = Template.bind({});
MaxSize.args = {
kind: miniFile,
};
export const ZipOnly = Template.bind({});
ZipOnly.args = {
kind: zipOnly,
};
export const AllowClearAfterUpload = Template.bind({});
AllowClearAfterUpload.args = {
allowClear: true,
};
export const ImmediateUpload = Template.bind({});
ImmediateUpload.args = {
immediate: true,
};
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,
};
export const ImmediateUploadAbort = Template.bind({});
ImmediateUploadAbort.args = {
immediate: true,
client: {
create: async () => ({ file: { id: 'test' } }),
upload: async () => {
await sleep(60000);
},
delete: async () => {},
} as unknown as FilesClient,
};

View file

@ -0,0 +1,203 @@
/*
* 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 { act } from 'react-dom/test-utils';
import { registerTestBed } from '@kbn/test-jest-helpers';
import { EuiFilePicker } from '@elastic/eui';
import {
FileKindsRegistryImpl,
setFileKindsRegistry,
getFileKindsRegistry,
} from '../../../common/file_kinds_registry';
import { createMockFilesClient } from '../../mocks';
import { FileJSON } from '../../../common';
import { FilesContext } from '../context';
import { UploadFile, Props } from './upload_file';
describe('UploadFile', () => {
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
let onDone: jest.Mock;
let onError: jest.Mock;
let client: ReturnType<typeof createMockFilesClient>;
async function initTestBed(props?: Partial<Props>) {
const createTestBed = registerTestBed((p: Props) => (
<FilesContext>
<UploadFile {...p} />
</FilesContext>
));
const testBed = await createTestBed({
client,
kind: 'test',
onDone,
onError,
...props,
});
const baseTestSubj = `filesUploadFile`;
const testSubjects = {
base: baseTestSubj,
uploadButton: `${baseTestSubj}.uploadButton`,
retryButton: `${baseTestSubj}.retryButton`,
cancelButton: `${baseTestSubj}.cancelButton`,
errorMessage: `${baseTestSubj}.error`,
successIcon: `${baseTestSubj}.uploadSuccessIcon`,
};
return {
...testBed,
actions: {
addFiles: (files: File[]) =>
act(async () => {
testBed.component.find(EuiFilePicker).props().onChange!(files as unknown as FileList);
await sleep(1);
testBed.component.update();
}),
upload: (retry = false) =>
act(async () => {
testBed
.find(retry ? testSubjects.retryButton : testSubjects.uploadButton)
.simulate('click');
await sleep(1);
testBed.component.update();
}),
abort: () =>
act(() => {
testBed.find(testSubjects.cancelButton).simulate('click');
testBed.component.update();
}),
wait: (ms: number) =>
act(async () => {
await sleep(ms);
testBed.component.update();
}),
},
testSubjects,
};
}
beforeAll(() => {
setFileKindsRegistry(new FileKindsRegistryImpl());
getFileKindsRegistry().register({
id: 'test',
maxSizeBytes: 10000,
http: {},
});
});
beforeEach(() => {
client = createMockFilesClient();
onDone = jest.fn();
onError = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('shows the success message when upload completes', async () => {
client.create.mockResolvedValue({ file: { id: 'test', size: 1 } as FileJSON });
client.upload.mockResolvedValue({ size: 1, ok: true });
const { actions, exists, testSubjects } = await initTestBed();
await actions.addFiles([{ name: 'test', size: 1 } as File]);
await actions.upload();
await sleep(1000);
expect(exists(testSubjects.errorMessage)).toBe(false);
expect(exists(testSubjects.successIcon)).toBe(true);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('does not show the upload button for "immediate" uploads', async () => {
client.create.mockResolvedValue({ file: { id: 'test' } as FileJSON });
client.upload.mockImplementation(() => sleep(100).then(() => ({ ok: true, size: 1 })));
const { actions, exists, testSubjects } = await initTestBed({ onDone, immediate: true });
expect(exists(testSubjects.uploadButton)).toBe(false);
await actions.addFiles([{ name: 'test', size: 1 } as File]);
expect(exists(testSubjects.uploadButton)).toBe(false);
await actions.wait(100);
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
it('allows users to cancel uploads', async () => {
client.create.mockResolvedValue({ file: { id: 'test' } as FileJSON });
client.upload.mockImplementation(() => sleep(1000).then(() => ({ ok: true, size: 1 })));
const { actions, testSubjects, find } = await initTestBed();
await actions.addFiles([{ name: 'test', size: 1 } as File]);
await actions.upload();
expect(find(testSubjects.cancelButton).props().disabled).toBe(false);
actions.abort();
await sleep(1000);
expect(onDone).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('does not show error messages while loading', async () => {
client.create.mockResolvedValue({ file: { id: 'test' } as FileJSON });
client.upload.mockImplementation(async () => {
await sleep(100);
throw new Error('stop!');
});
const { actions, exists, testSubjects } = await initTestBed();
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.addFiles([{ name: 'test', size: 1 } as File]);
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.upload();
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.wait(1000);
expect(exists(testSubjects.uploadButton)).toBe(false); // No upload button
expect(exists(testSubjects.errorMessage)).toBe(true);
await actions.upload(true);
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.wait(500);
expect(exists(testSubjects.errorMessage)).toBe(true);
expect(onDone).not.toHaveBeenCalled();
});
it('shows error messages if there are any', async () => {
client.create.mockResolvedValue({ file: { id: 'test', size: 10001 } as FileJSON });
client.upload.mockImplementation(async () => {
await sleep(100);
throw new Error('stop!');
});
const { actions, exists, testSubjects, find } = await initTestBed();
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.addFiles([{ name: 'test', size: 1 } as File]);
await actions.upload();
await actions.wait(1000);
expect(find(testSubjects.errorMessage).text()).toMatch(/stop/i);
expect(onDone).not.toHaveBeenCalled();
});
it('prevents uploads if there is an issue', async () => {
client.create.mockResolvedValue({ file: { id: 'test', size: 10001 } as FileJSON });
const { actions, exists, testSubjects, find } = await initTestBed();
expect(exists(testSubjects.errorMessage)).toBe(false);
await actions.addFiles([{ name: 'test', size: 10001 } as File]);
expect(exists(testSubjects.errorMessage)).toBe(true);
expect(find(testSubjects.errorMessage).text()).toMatch(/File is too large/);
expect(onDone).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 { EuiFilePicker } from '@elastic/eui';
import React, { type FunctionComponent, useRef, useEffect, useMemo } from 'react';
import { FilesClient } from '../../types';
import { useFilesContext } from '../context';
import { UploadFile as Component } from './upload_file.component';
import { createUploadState } from './upload_state';
import { context } from './context';
/**
* An object representing an uploadded file
*/
interface UploadedFile {
/**
* The ID that was generated for the uploaded file
*/
id: string;
/**
* The kind of the file that was passed in to this component
*/
kind: string;
}
/**
* UploadFile component props
*/
export interface Props<Kind extends string = string> {
/**
* A file kind that should be registered during plugin startup. See {@link FileServiceStart}.
*/
kind: Kind;
/**
* A files client that will be used process uploads.
*/
client: FilesClient;
/**
* Allow users to clear a file after uploading.
*
* @note this will NOT delete an uploaded file.
*/
allowClear?: boolean;
/**
* Start uploading the file as soon as it is provided
* by the user.
*/
immediate?: boolean;
/**
* Metadata that you want to associate with any uploaded files
*/
meta?: Record<string, unknown>;
/**
* Whether this component should display a "done" state after processing an
* upload or return to the initial state to allow for another upload.
*
* @default false
*/
allowRepeatedUploads?: boolean;
/**
* Called when the an upload process fully completes
*/
onDone: (files: UploadedFile[]) => void;
/**
* Called when an error occurs during upload
*/
onError?: (e: Error) => void;
}
/**
* This component is intended as a wrapper around EuiFilePicker with some opinions
* about upload UX. It is optimised for use in modals, flyouts or forms.
*
* In order to use this component you must register your file kind with {@link FileKindsRegistry}
*/
export const UploadFile = <Kind extends string = string>({
meta,
client,
onDone,
onError,
allowClear,
kind: kindId,
immediate = false,
allowRepeatedUploads = false,
}: Props<Kind>): ReturnType<FunctionComponent> => {
const { registry } = useFilesContext();
const ref = useRef<null | EuiFilePicker>(null);
const uploadState = useMemo(
() =>
createUploadState({
client,
fileKind: registry.get(kindId),
allowRepeatedUploads,
}),
[client, kindId, allowRepeatedUploads, registry]
);
/**
* Hook state into component callbacks
*/
useEffect(() => {
const subs = [
uploadState.clear$.subscribe(() => {
ref.current?.removeFiles();
}),
uploadState.done$.subscribe((n) => n && onDone(n)),
uploadState.error$.subscribe((e) => e && onError?.(e)),
];
return () => subs.forEach((sub) => sub.unsubscribe());
}, [uploadState, onDone, onError]);
return (
<context.Provider value={uploadState}>
<Component ref={ref} meta={meta} immediate={immediate} allowClear={allowClear} />
</context.Provider>
);
};
/* eslint-disable import/no-default-export */
export default UploadFile;

View file

@ -0,0 +1,175 @@
/*
* 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 { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import { of, delay, merge, tap, mergeMap } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import type { FileKind, FileJSON } from '../../../common';
import { createMockFilesClient } from '../../mocks';
import type { FilesClient } from '../../types';
import { UploadState } from './upload_state';
const getTestScheduler = () =>
new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
describe('UploadState', () => {
let filesClient: DeeplyMockedKeys<FilesClient>;
let uploadState: UploadState;
let testScheduler: TestScheduler;
beforeEach(() => {
filesClient = createMockFilesClient();
filesClient.create.mockReturnValue(of({ file: { id: 'test' } as FileJSON }) as any);
filesClient.upload.mockReturnValue(of(undefined) as any);
uploadState = new UploadState(
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind,
filesClient
);
testScheduler = getTestScheduler();
});
it('uploads all provided files and reports errors', async () => {
testScheduler.run(({ expectObservable, cold, flush }) => {
const file1 = { name: 'test', size: 1 } as File;
const file2 = { name: 'test 2', size: 1 } as File;
uploadState.setFiles([file1, file2]);
// Simulate upload being triggered async
const upload$ = cold('--a|').pipe(tap(uploadState.upload));
expectObservable(upload$).toBe('--a|');
expectObservable(uploadState.uploading$).toBe('a-(bc)', {
a: false,
b: true,
c: false,
});
expectObservable(uploadState.files$).toBe('a-(bc)', {
a: [
{ file: file1, status: 'idle' },
{ file: file2, status: 'idle' },
],
b: [
{ file: file1, status: 'uploading' },
{ file: file2, status: 'uploading' },
],
c: [
{ file: file1, status: 'uploaded', id: 'test' },
{ file: file2, status: 'uploaded', id: 'test' },
],
});
flush();
expect(filesClient.create).toHaveBeenCalledTimes(2);
expect(filesClient.upload).toHaveBeenCalledTimes(2);
expect(filesClient.delete).not.toHaveBeenCalled();
});
});
it('attempts to clean up all files when aborting', async () => {
testScheduler.run(({ expectObservable, cold, flush }) => {
filesClient.create.mockReturnValue(
of({ file: { id: 'test' } as FileJSON }).pipe(delay(2)) as any
);
filesClient.upload.mockReturnValue(of(undefined).pipe(delay(10)) as any);
filesClient.delete.mockReturnValue(of(undefined) as any);
const file1 = { name: 'test' } as File;
const file2 = { name: 'test 2.png' } as File;
uploadState.setFiles([file1, file2]);
// Simulate upload being triggered async
const upload$ = cold('-0|').pipe(tap(() => uploadState.upload({ myMeta: true })));
const abort$ = cold(' --1|').pipe(tap(uploadState.abort));
expectObservable(merge(upload$, abort$)).toBe('-01|');
expectObservable(uploadState.error$).toBe('0---', [undefined]);
expectObservable(uploadState.uploading$).toBe('ab-c', {
a: false,
b: true,
c: false,
});
expectObservable(uploadState.files$).toBe('ab-c', {
a: [
{ file: file1, status: 'idle' },
{ file: file2, status: 'idle' },
],
b: [
{ file: file1, status: 'uploading' },
{ file: file2, status: 'uploading' },
],
c: [
{ file: file1, status: 'upload_failed' },
{ file: file2, status: 'upload_failed' },
],
});
flush();
expect(filesClient.create).toHaveBeenCalledTimes(2);
expect(filesClient.create).toHaveBeenNthCalledWith(1, {
kind: 'test',
meta: { myMeta: true },
mimeType: undefined,
name: 'test',
});
expect(filesClient.create).toHaveBeenNthCalledWith(2, {
kind: 'test',
meta: { myMeta: true },
mimeType: 'image/png',
name: 'test 2',
});
expect(filesClient.upload).toHaveBeenCalledTimes(2);
expect(filesClient.delete).toHaveBeenCalledTimes(2);
});
});
it('throws for files that are too large', () => {
testScheduler.run(({ expectObservable }) => {
const file = {
name: 'test',
size: 1001,
} as File;
uploadState.setFiles([file]);
expectObservable(uploadState.files$).toBe('a', {
a: [
{
file,
status: 'idle',
error: new Error('File is too large. Maximum size is 1,000 bytes.'),
},
],
});
});
});
it('option "allowRepeatedUploads" calls clear after upload is done', () => {
testScheduler.run(({ expectObservable, cold }) => {
uploadState = new UploadState(
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind,
filesClient,
{ allowRepeatedUploads: true }
);
const file1 = { name: 'test' } as File;
const file2 = { name: 'test 2.png' } as File;
uploadState.setFiles([file1, file2]);
const upload$ = cold('-0|').pipe(mergeMap(() => uploadState.upload({ myMeta: true })));
expectObservable(upload$, ' --^').toBe('---0|', [undefined]);
expectObservable(uploadState.clear$, '^').toBe(' ---0-', [undefined]);
});
});
});

View file

@ -0,0 +1,247 @@
/*
* 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 {
of,
map,
zip,
from,
race,
take,
filter,
Subject,
finalize,
forkJoin,
mergeMap,
switchMap,
catchError,
shareReplay,
ReplaySubject,
BehaviorSubject,
type Observable,
combineLatest,
distinctUntilChanged,
} from 'rxjs';
import type { FileKind, FileJSON } from '../../../common/types';
import type { FilesClient } from '../../types';
import { i18nTexts } from './i18n_texts';
import { createStateSubject, type SimpleStateSubject, parseFileName } from './util';
interface FileState {
file: File;
status: 'idle' | 'uploading' | 'uploaded' | 'upload_failed';
id?: string;
error?: Error;
}
type Upload = SimpleStateSubject<FileState>;
interface DoneNotification {
id: string;
kind: string;
}
interface UploadOptions {
allowRepeatedUploads?: boolean;
}
export class UploadState {
private readonly abort$ = new Subject<void>();
private readonly files$$ = new BehaviorSubject<Upload[]>([]);
public readonly files$ = this.files$$.pipe(
switchMap((files$) => (files$.length ? zip(...files$) : of([])))
);
public readonly clear$ = new Subject<void>();
public readonly error$ = new BehaviorSubject<undefined | Error>(undefined);
public readonly uploading$ = new BehaviorSubject(false);
public readonly done$ = new Subject<undefined | DoneNotification[]>();
constructor(
private readonly fileKind: FileKind,
private readonly client: FilesClient,
private readonly opts: UploadOptions = { allowRepeatedUploads: false }
) {
const latestFiles$ = this.files$$.pipe(switchMap((files$) => combineLatest(files$)));
latestFiles$
.pipe(
map((files) => files.some((file) => file.status === 'uploading')),
distinctUntilChanged()
)
.subscribe(this.uploading$);
latestFiles$
.pipe(
map((files) => {
const errorFile = files.find((file) => Boolean(file.error));
return errorFile ? errorFile.error : undefined;
}),
filter(Boolean)
)
.subscribe(this.error$);
latestFiles$
.pipe(
filter(
(files) => Boolean(files.length) && files.every((file) => file.status === 'uploaded')
),
map((files) => files.map((file) => ({ id: file.id!, kind: this.fileKind.id })))
)
.subscribe(this.done$);
}
public isUploading(): boolean {
return this.uploading$.getValue();
}
private validateFiles(files: File[]): undefined | string {
if (
this.fileKind.maxSizeBytes != null &&
files.some((file) => file.size > this.fileKind.maxSizeBytes!)
) {
return i18nTexts.fileTooLarge(String(this.fileKind.maxSizeBytes));
}
return;
}
public setFiles = (files: File[]): void => {
if (this.isUploading()) {
throw new Error('Cannot update files while uploading');
}
if (!files.length) {
this.done$.next(undefined);
this.error$.next(undefined);
}
const validationError = this.validateFiles(files);
this.files$$.next(
files.map((file) =>
createStateSubject<FileState>({
file,
status: 'idle',
error: validationError ? new Error(validationError) : undefined,
})
)
);
};
public abort = (): void => {
if (!this.isUploading()) {
throw new Error('No upload in progress');
}
this.abort$.next();
};
clear = (): void => {
this.setFiles([]);
this.clear$.next();
};
/**
* Do not throw from this method, it is intended to work with {@link forkJoin} from rxjs which
* unsubscribes from all observables if one of them throws.
*/
private uploadFile = (
file$: SimpleStateSubject<FileState>,
abort$: Observable<void>,
meta?: unknown
): Observable<void | Error> => {
const abortController = new AbortController();
const abortSignal = abortController.signal;
const { file, status } = file$.getValue();
if (!['idle', 'upload_failed'].includes(status)) {
return of(undefined);
}
let uploadTarget: undefined | FileJSON;
let erroredOrAborted = false;
file$.setState({ status: 'uploading', error: undefined });
const { name, mime } = parseFileName(file.name);
return from(
this.client.create({
kind: this.fileKind.id,
name,
mimeType: mime,
meta: meta as Record<string, unknown>,
})
).pipe(
mergeMap((result) => {
uploadTarget = result.file;
return race(
abort$.pipe(
map(() => {
abortController.abort();
throw new Error('Abort!');
})
),
this.client.upload({
body: file,
id: uploadTarget.id,
kind: this.fileKind.id,
abortSignal,
})
);
}),
map(() => {
file$.setState({ status: 'uploaded', id: uploadTarget?.id });
}),
catchError((e) => {
erroredOrAborted = true;
const isAbortError = e.message === 'Abort!';
file$.setState({ status: 'upload_failed', error: isAbortError ? undefined : e });
return of(isAbortError ? undefined : e);
}),
finalize(() => {
if (erroredOrAborted && uploadTarget) {
this.client.delete({ id: uploadTarget.id, kind: this.fileKind.id });
}
})
);
};
public upload = (meta?: unknown): Observable<void> => {
if (this.isUploading()) {
throw new Error('Upload already in progress');
}
const abort$ = new ReplaySubject<void>(1);
const sub = this.abort$.subscribe(abort$);
const upload$ = this.files$$.pipe(
take(1),
switchMap((files$) => {
return forkJoin(files$.map((file$) => this.uploadFile(file$, abort$, meta)));
}),
map(() => undefined),
finalize(() => {
if (this.opts.allowRepeatedUploads) this.clear();
sub.unsubscribe();
}),
shareReplay()
);
upload$.subscribe();
return upload$;
};
}
export const createUploadState = ({
fileKind,
client,
...options
}: {
fileKind: FileKind;
client: FilesClient;
} & UploadOptions) => {
return new UploadState(fileKind, client, options);
};

View file

@ -0,0 +1,10 @@
/*
* 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 { SimpleStateSubject, createStateSubject } from './simple_state_subject';
export { parseFileName } from './parse_file_name';

View file

@ -0,0 +1,38 @@
/*
* 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 { parseFileName } from './parse_file_name';
describe('parseFileName', () => {
test('file.png', () => {
expect(parseFileName('file.png')).toEqual({
name: 'file',
mime: 'image/png',
});
});
test(' Something_* really -=- strange.abc.wav', () => {
expect(parseFileName(' Something_* really -=- strange.abc.wav')).toEqual({
name: 'Something__ really ___ strange_abc',
mime: 'audio/wave',
});
});
test('!@#$%^&*()', () => {
expect(parseFileName('!@#$%^&*()')).toEqual({
name: '__________',
mime: undefined,
});
});
test('reallylong.repeat(100).dmg', () => {
expect(parseFileName('reallylong'.repeat(100) + '.dmg')).toEqual({
name: 'reallylong'.repeat(100).slice(0, 256),
mime: 'application/x-apple-diskimage',
});
});
});

View file

@ -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 mime from 'mime-types';
interface Result {
name: string;
mime?: string;
}
export function parseFileName(fileName: string): Result {
const withoutExt = fileName.substring(0, fileName.lastIndexOf('.')) || fileName;
return {
name: withoutExt
.trim()
.slice(0, 256)
.replace(/[^a-z0-9\s]/gi, '_'), // replace invalid chars
mime: mime.lookup(fileName) || undefined,
};
}

View file

@ -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 { merge } from 'lodash';
import { BehaviorSubject } from 'rxjs';
export class SimpleStateSubject<S extends object = {}> extends BehaviorSubject<S> {
constructor(initialState: S) {
super(initialState);
}
public getSnapshot() {
return this.getValue();
}
public setState(nextState: Partial<S>): void {
this.next(merge({}, this.getSnapshot(), nextState));
}
}
export const createStateSubject = <S extends object = {}>(initialState: S) =>
new SimpleStateSubject(initialState);

View file

@ -0,0 +1,13 @@
/*
* 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';
import useObservable from 'react-use/lib/useObservable';
export function useBehaviorSubject<T>(o$: BehaviorSubject<T>) {
return useObservable(o$, o$.getValue());
}

View file

@ -127,12 +127,12 @@ export function createFilesClient({
body: JSON.stringify(body),
});
},
upload: ({ kind, ...args }) => {
upload: ({ kind, abortSignal, ...args }) => {
return http.put(apiRoutes.getUploadRoute(scopedFileKind ?? kind, args.id), {
headers: {
'Content-Type': 'application/octet-stream',
},
signal: abortSignal,
body: args.body as BodyInit,
});
},

View file

@ -13,6 +13,13 @@ export type {
FilesClientFactory,
FilesClientResponses,
} from './types';
export {
FilesContext,
Image,
type ImageProps,
UploadFile,
type UploadFileProps,
} from './components';
export function plugin() {
return new FilesPlugin();

View file

@ -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 type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import type { FilesClient } from './types';
// TODO: Remove this once we have access to the shared file client mock
export const createMockFilesClient = (): DeeplyMockedKeys<FilesClient> => ({
create: jest.fn(),
delete: jest.fn(),
download: jest.fn(),
find: jest.fn(),
getById: jest.fn(),
getDownloadHref: jest.fn(),
getMetrics: jest.fn(),
getShare: jest.fn(),
list: jest.fn(),
listShares: jest.fn(),
publicDownload: jest.fn(),
share: jest.fn(),
unshare: jest.fn(),
update: jest.fn(),
upload: jest.fn(),
});

View file

@ -6,8 +6,14 @@
*/
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import {
getFileKindsRegistry,
setFileKindsRegistry,
FileKindsRegistryImpl,
} from '../common/file_kinds_registry';
import type { FilesClientFactory } from './types';
import { createFilesClient } from './files_client';
import { FileKind } from '../common';
/**
* Public setup-phase contract
@ -18,31 +24,48 @@ export interface FilesSetup {
* registered {@link FileKind}.
*/
filesClientFactory: FilesClientFactory;
/**
* Register a {@link FileKind} which allows for specifying details about the files
* that will be uploaded.
*
* @param {FileKind} fileKind - the file kind to register
*/
registerFileKind(fileKind: FileKind): void;
}
export type FilesStart = FilesSetup;
export type FilesStart = Pick<FilesSetup, 'filesClientFactory'>;
/**
* Bringing files to Kibana
*/
export class FilesPlugin implements Plugin<FilesSetup, FilesStart> {
private api: undefined | FilesSetup;
private filesClientFactory: undefined | FilesClientFactory;
constructor() {
setFileKindsRegistry(new FileKindsRegistryImpl());
}
setup(core: CoreSetup): FilesSetup {
this.api = {
filesClientFactory: {
asScoped(fileKind: string) {
return createFilesClient({ fileKind, http: core.http });
},
asUnscoped() {
return createFilesClient({ http: core.http });
},
this.filesClientFactory = {
asScoped(fileKind: string) {
return createFilesClient({ fileKind, http: core.http });
},
asUnscoped() {
return createFilesClient({ http: core.http });
},
};
return {
filesClientFactory: this.filesClientFactory,
registerFileKind: (fileKind: FileKind) => {
getFileKindsRegistry().register(fileKind);
},
};
return this.api;
}
start(core: CoreStart): FilesStart {
return this.api!;
return {
filesClientFactory: this.filesClientFactory!,
};
}
}

View file

@ -31,8 +31,8 @@ type UnscopedClientMethodFrom<E extends HttpApiInterfaceEntryDefinition> = (
/**
* @param args - Input to the endpoint which includes body, params and query of the RESTful endpoint.
*/
type ClientMethodFrom<E extends HttpApiInterfaceEntryDefinition> = (
args: Parameters<UnscopedClientMethodFrom<E>>[0] & { kind: string }
type ClientMethodFrom<E extends HttpApiInterfaceEntryDefinition, ExtraArgs extends {} = {}> = (
args: Parameters<UnscopedClientMethodFrom<E>>[0] & { kind: string } & ExtraArgs
) => Promise<E['output']>;
interface GlobalEndpoints {
@ -96,7 +96,7 @@ export interface FilesClient extends GlobalEndpoints {
*
* @param args - upload file args
*/
upload: ClientMethodFrom<UploadFileKindHttpEndpoint>;
upload: ClientMethodFrom<UploadFileKindHttpEndpoint, { abortSignal?: AbortSignal }>;
/**
* Stream a download of the file object's content.
*

View file

@ -11,7 +11,6 @@ import {
elasticsearchServiceMock,
loggingSystemMock,
savedObjectsServiceMock,
httpServiceMock,
} from '@kbn/core/server/mocks';
import { Readable } from 'stream';
import { promisify } from 'util';
@ -24,7 +23,7 @@ import {
FileKindsRegistryImpl,
getFileKindsRegistry,
setFileKindsRegistry,
} from '../file_kinds_registry';
} from '../../common/file_kinds_registry';
import { InternalFileShareService } from '../file_share_service';
import { FileMetadataClient } from '../file_client';
import { SavedObjectsFileMetadataClient } from '../file_client/file_metadata_client/adapters/saved_objects';
@ -41,7 +40,7 @@ describe('File', () => {
const fileKind = 'fileKind';
beforeAll(() => {
setFileKindsRegistry(new FileKindsRegistryImpl(httpServiceMock.createRouter()));
setFileKindsRegistry(new FileKindsRegistryImpl());
getFileKindsRegistry().register({ http: {}, id: fileKind });
});

View file

@ -28,7 +28,7 @@ import {
} from './file_action_types';
import { InternalFileService } from './internal_file_service';
import { FileServiceStart } from './file_service';
import { FileKindsRegistry } from '../file_kinds_registry';
import { FileKindsRegistry } from '../../common/file_kinds_registry';
import { SavedObjectsFileMetadataClient } from '../file_client';
/**

View file

@ -12,7 +12,7 @@ import { BlobStorageService } from '../blob_storage_service';
import { InternalFileShareService } from '../file_share_service';
import { FileMetadata, File as IFile, FileKind, FileJSON, FilesMetrics } from '../../common';
import { File, toJSON } from '../file';
import { FileKindsRegistry } from '../file_kinds_registry';
import { FileKindsRegistry } from '../../common/file_kinds_registry';
import { FileNotFoundError } from './errors';
import type { FileMetadataClient } from '../file_client';
import type {

View file

@ -6,7 +6,6 @@
*/
import { CoreStart, ElasticsearchClient } from '@kbn/core/server';
import { httpServiceMock } from '@kbn/core/server/mocks';
import {
createTestServers,
createRootWithCorePlugins,
@ -22,7 +21,7 @@ import {
FileKindsRegistryImpl,
getFileKindsRegistry,
setFileKindsRegistry,
} from '../file_kinds_registry';
} from '../../common/file_kinds_registry';
import { BlobStorageService } from '../blob_storage_service';
import { FileServiceStart, FileServiceFactory } from '../file_service';
import type { CreateFileArgs } from '../file_service/file_action_types';
@ -52,7 +51,7 @@ describe('FileService', () => {
coreSetup = await kbnRoot.setup();
FileServiceFactory.setup(coreSetup.savedObjects);
coreStart = await kbnRoot.start();
setFileKindsRegistry(new FileKindsRegistryImpl(httpServiceMock.createRouter()));
setFileKindsRegistry(new FileKindsRegistryImpl());
const fileKindsRegistry = getFileKindsRegistry();
fileKindsRegistry.register({
id: fileKind,

View file

@ -14,17 +14,18 @@ import type {
} from '@kbn/core/server';
import { PLUGIN_ID } from '../common/constants';
import { BlobStorageService } from './blob_storage_service';
import { FileServiceFactory } from './file_service';
import type { FilesPluginSetupDependencies, FilesSetup, FilesStart } from './types';
import {
setFileKindsRegistry,
getFileKindsRegistry,
FileKindsRegistryImpl,
} from './file_kinds_registry';
} from '../common/file_kinds_registry';
import { BlobStorageService } from './blob_storage_service';
import { FileServiceFactory } from './file_service';
import type { FilesPluginSetupDependencies, FilesSetup, FilesStart } from './types';
import type { FilesRequestHandlerContext, FilesRouter } from './routes/types';
import { registerRoutes } from './routes';
import { registerRoutes, registerFileKindRoutes } from './routes';
import { Counters, registerUsageCollector } from './usage';
export class FilesPlugin implements Plugin<FilesSetup, FilesStart, FilesPluginSetupDependencies> {
@ -62,7 +63,11 @@ export class FilesPlugin implements Plugin<FilesSetup, FilesStart, FilesPluginSe
const router: FilesRouter = core.http.createRouter();
registerRoutes(router);
setFileKindsRegistry(new FileKindsRegistryImpl(router));
setFileKindsRegistry(
new FileKindsRegistryImpl((fk) => {
registerFileKindRoutes(router, fk);
})
);
registerUsageCollector({
usageCollection,
getFileService: () => this.fileServiceFactory?.asInternal(),

View file

@ -7,8 +7,8 @@
import { schema } from '@kbn/config-schema';
const ALPHA_NUMERIC_WITH_SPACES_REGEX = /^[a-z0-9\s]+$/i;
const ALPHA_NUMERIC_WITH_SPACES_EXT_REGEX = /^[a-z0-9\s\.]+$/i;
const ALPHA_NUMERIC_WITH_SPACES_REGEX = /^[a-z0-9\s_]+$/i;
const ALPHA_NUMERIC_WITH_SPACES_EXT_REGEX = /^[a-z0-9\s\._]+$/i;
function alphanumericValidation(v: string) {
return ALPHA_NUMERIC_WITH_SPACES_REGEX.test(v)
@ -19,7 +19,7 @@ function alphanumericValidation(v: string) {
function alphanumericWithExtValidation(v: string) {
return ALPHA_NUMERIC_WITH_SPACES_EXT_REGEX.test(v)
? undefined
: 'Only alphanumeric characters, spaces (" ") and dots (".") are allowed';
: 'Only alphanumeric characters, spaces (" "), dots (".") and underscores ("_") are allowed';
}
export const fileName = schema.string({

View file

@ -11,6 +11,8 @@ import * as find from './find';
import * as metrics from './metrics';
import * as publicDownload from './public_facing/download';
export { registerFileKindRoutes } from './file_kind';
export function registerRoutes(router: FilesRouter) {
[find, metrics, publicDownload].forEach((endpoint) => {
endpoint.register(router);

View file

@ -13,7 +13,7 @@ import {
} from '@kbn/core/test_helpers/kbn_server';
import pRetry from 'p-retry';
import { FileJSON } from '../../common';
import { getFileKindsRegistry } from '../file_kinds_registry';
import { getFileKindsRegistry } from '../../common/file_kinds_registry';
export type TestEnvironmentUtils = Awaited<ReturnType<typeof setupIntegrationEnvironment>>;
@ -93,7 +93,7 @@ export async function setupIntegrationEnvironment() {
* Register a test file type
*/
const testHttpConfig = { tags: ['access:myapp'] };
getFileKindsRegistry().register({
const myFileKind = {
id: fileKind,
blobStoreSettings: {
esFixedSizeIndex: { index: testIndex },
@ -107,7 +107,8 @@ export async function setupIntegrationEnvironment() {
list: testHttpConfig,
share: testHttpConfig,
},
});
};
getFileKindsRegistry().register(myFileKind);
const coreStart = await root.start();
const esClient = coreStart.elasticsearch.client.asInternalUser;