mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
21d01719eb
commit
f2bb8974f7
38 changed files with 1528 additions and 50 deletions
|
@ -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"],
|
||||
|
|
|
@ -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 {
|
|
@ -10,5 +10,6 @@
|
|||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": [],
|
||||
"requiredBundles": ["kibanaUtils"],
|
||||
"optionalPlugins": ["security", "usageCollection"]
|
||||
}
|
||||
|
|
35
x-pack/plugins/files/public/components/context.tsx
Normal file
35
x-pack/plugins/files/public/components/context.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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)!;
|
|
@ -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 },
|
||||
}),
|
||||
};
|
20
x-pack/plugins/files/public/components/upload_file/index.tsx
Normal file
20
x-pack/plugins/files/public/components/upload_file/index.tsx
Normal 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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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';
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
|
@ -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());
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
28
x-pack/plugins/files/public/mocks.ts
Normal file
28
x-pack/plugins/files/public/mocks.ts
Normal 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(),
|
||||
});
|
|
@ -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!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue