diff --git a/docs/spec.md b/docs/spec.md index fe252024..e7cf5257 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -111,6 +111,7 @@ type Spec = ArraySpec | BooleanSpec | NumberSpec | ObjectSpec | StringSpec; | viewSpec.monacoParams | `object` | | [Parameters](#monacoparams) that must be passed to Monaco editor | | viewSpec.placeholder | `string` | | A short hint displayed in the field before the user enters the value | | viewSpec.themeLabel | `'normal'` `'info'` `'danger'` `'warning'` `'success'` `'unknown'` | | Label color | +| viewSpec.fileInput | `object` | | [Parameters](#FileInput) that must be passed to file input | #### SizeParams @@ -127,6 +128,14 @@ type Spec = ArraySpec | BooleanSpec | NumberSpec | ObjectSpec | StringSpec; | language | `string` | yes | Syntax highlighting language | | fontSize | `string` | | Font size | +#### FileInput + +| Property | Type | Required | Description | +| :----------- | :---------------------------------------------------------------------------- | :------: | :------------------------------------------------------------------------------------- | +| accept | `string` | | Acceptable file extensions, for example: `'.png'`, `'audio/\*'`, `'.jpg, .jpeg, .png'` | +| readAsMethod | `'readAsArrayBuffer'` `'readAsBinaryString'` `'readAsDataURL'` `'readAsText'` | | File reading method | +| ignoreText | `boolean` | | For `true`, will show the `File uploaded` stub instead of the field value | + #### Link A component that serves as a wrapper for the value, if necessary, rendering the value as a link. diff --git a/src/lib/core/constants.ts b/src/lib/core/constants.ts index 9e96524a..49edf9b1 100644 --- a/src/lib/core/constants.ts +++ b/src/lib/core/constants.ts @@ -5,3 +5,9 @@ export enum SpecTypes { Object = 'object', String = 'string', } + +export type ReadAsMethod = + | 'readAsArrayBuffer' + | 'readAsBinaryString' + | 'readAsDataURL' + | 'readAsText'; diff --git a/src/lib/core/types/specs.ts b/src/lib/core/types/specs.ts index 7b048dd4..465c0ce6 100644 --- a/src/lib/core/types/specs.ts +++ b/src/lib/core/types/specs.ts @@ -1,6 +1,6 @@ import {LabelProps} from '@gravity-ui/uikit'; -import {SpecTypes} from '../constants'; +import {ReadAsMethod, SpecTypes} from '../constants'; import {ArrayValue, ObjectValue} from './'; @@ -118,6 +118,11 @@ export interface StringSpec { hideValues?: string[]; placeholder?: string; themeLabel?: LabelProps['theme']; + fileInput?: { + accept?: string; + readAsMethod?: ReadAsMethod; + ignoreText?: boolean; + }; }; } diff --git a/src/lib/kit/components/Inputs/FileInput/FileInput.scss b/src/lib/kit/components/Inputs/FileInput/FileInput.scss new file mode 100644 index 00000000..15bbdf9d --- /dev/null +++ b/src/lib/kit/components/Inputs/FileInput/FileInput.scss @@ -0,0 +1,24 @@ +@import '../../../styles/variables.scss'; + +.#{$ns}file-input { + display: flex; + + &__input { + opacity: 0; + position: absolute; + clip: rect(0 0 0 0); + width: 1px; + height: 1px; + margin: -1px; + } + + &__file-name { + display: block; + margin: auto 10px; + max-width: 160px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: var(--yc-color-text-secondary); + } +} diff --git a/src/lib/kit/components/Inputs/FileInput/FileInput.tsx b/src/lib/kit/components/Inputs/FileInput/FileInput.tsx new file mode 100644 index 00000000..961fc031 --- /dev/null +++ b/src/lib/kit/components/Inputs/FileInput/FileInput.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Label} from '@gravity-ui/uikit'; + +import {StringInputProps} from '../../../../core'; +import i18n from '../../../../kit/i18n'; +import {block} from '../../../utils'; + +import {readFile} from './utils'; + +import './FileInput.scss'; + +const b = block('file-input'); + +export const FileInput: React.FC = ({input, spec}) => { + const {value, onChange} = input; + + const inputRef = React.useRef(null); + + const [fileName, setFileName] = React.useState(''); + + const handleClick = React.useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleDownload = React.useCallback( + async (file: Blob) => await readFile(file, spec.viewSpec.fileInput?.readAsMethod), + [spec.viewSpec.fileInput?.readAsMethod], + ); + + const handleReset = React.useCallback(() => { + setFileName(''); + onChange(''); + }, [onChange]); + + const handleInputChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files; + + if (file && file.length > 0) { + setFileName(file[0].name); + const data = (await handleDownload(file[0])) as string; + onChange(data); + } + }, + [handleDownload, onChange], + ); + + const fileNameContent = React.useMemo(() => { + if (value) { + if (fileName) { + return {fileName}; + } + + return ( + + ); + } + + return null; + }, [fileName, value]); + + return ( +
+ + + {fileNameContent} + {value ? ( + + ) : null} +
+ ); +}; diff --git a/src/lib/kit/components/Inputs/FileInput/index.tsx b/src/lib/kit/components/Inputs/FileInput/index.tsx new file mode 100644 index 00000000..660f279c --- /dev/null +++ b/src/lib/kit/components/Inputs/FileInput/index.tsx @@ -0,0 +1 @@ +export * from './FileInput'; diff --git a/src/lib/kit/components/Inputs/FileInput/utils.ts b/src/lib/kit/components/Inputs/FileInput/utils.ts new file mode 100644 index 00000000..f4f43427 --- /dev/null +++ b/src/lib/kit/components/Inputs/FileInput/utils.ts @@ -0,0 +1,20 @@ +import {ReadAsMethod} from '../../../../core'; + +export function readFile( + file: Blob, + readAsMethod: ReadAsMethod = 'readAsBinaryString', +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + if (typeof reader[readAsMethod] !== 'function') { + reject(new Error(`Unknown parameter: ${readAsMethod}`)); + return; + } + + reader.addEventListener('load', () => resolve(reader.result)); + reader.addEventListener('error', () => reject(reader.error)); + + reader[readAsMethod](file); + }); +} diff --git a/src/lib/kit/components/Inputs/index.ts b/src/lib/kit/components/Inputs/index.ts index dd9c291d..ed07bed4 100644 --- a/src/lib/kit/components/Inputs/index.ts +++ b/src/lib/kit/components/Inputs/index.ts @@ -1,6 +1,7 @@ export * from './ArrayBase'; export * from './CardOneOf'; export * from './Checkbox'; +export * from './FileInput'; export * from './MultiSelect'; export * from './ObjectBase'; export * from './OneOf'; diff --git a/src/lib/kit/components/Views/FileInputView/FileInputView.tsx b/src/lib/kit/components/Views/FileInputView/FileInputView.tsx new file mode 100644 index 00000000..87bc3bc1 --- /dev/null +++ b/src/lib/kit/components/Views/FileInputView/FileInputView.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import {StringViewProps} from '../../../../core'; +import i18n from '../../../../kit/i18n'; +import {LongValue} from '../../../components'; + +export const FileInputView: React.FC = ({value, spec}) => ( + +); diff --git a/src/lib/kit/components/Views/FileInputView/index.ts b/src/lib/kit/components/Views/FileInputView/index.ts new file mode 100644 index 00000000..8aa8d191 --- /dev/null +++ b/src/lib/kit/components/Views/FileInputView/index.ts @@ -0,0 +1 @@ +export * from './FileInputView'; diff --git a/src/lib/kit/components/Views/index.ts b/src/lib/kit/components/Views/index.ts index f2fa676a..999d4edd 100644 --- a/src/lib/kit/components/Views/index.ts +++ b/src/lib/kit/components/Views/index.ts @@ -1,6 +1,7 @@ export * from './ArrayBaseView'; export * from './BaseView'; export * from './CardOneOfView'; +export * from './FileInputView'; export * from './MonacoInputView'; export * from './MultiSelectView'; export * from './NumberWithScaleView'; diff --git a/src/lib/kit/constants/config.tsx b/src/lib/kit/constants/config.tsx index ca8514ad..6da420c9 100644 --- a/src/lib/kit/constants/config.tsx +++ b/src/lib/kit/constants/config.tsx @@ -10,6 +10,8 @@ import { CardOneOfView, CardSection, Checkbox, + FileInput, + FileInputView, Group, Group2, MonacoInput, @@ -151,6 +153,7 @@ export const dynamicConfig: DynamicFormConfig = { textarea: {Component: TextArea}, select: {Component: Select}, base: {Component: Text}, + file_input: {Component: FileInput}, number_with_scale: {Component: NumberWithScale}, monaco_input: {Component: MonacoInput}, text_content: {Component: TextContent, independent: true}, @@ -245,6 +248,7 @@ export const dynamicCardConfig: DynamicFormConfig = { textarea: {Component: TextArea}, select: {Component: Select}, base: {Component: Text}, + file_input: {Component: FileInput}, number_with_scale: {Component: NumberWithScale}, monaco_input: {Component: MonacoInputCard}, text_content: {Component: TextContent, independent: true}, @@ -330,6 +334,7 @@ export const dynamicViewConfig: DynamicViewConfig = { textarea: {Component: TextAreaView}, select: {Component: BaseView}, base: {Component: BaseView}, + file_input: {Component: FileInputView}, number_with_scale: {Component: NumberWithScaleView}, monaco_input: {Component: MonacoView}, text_content: undefined, @@ -407,6 +412,7 @@ export const dynamicViewCardConfig: DynamicViewConfig = { textarea: {Component: TextAreaView}, select: {Component: BaseView}, base: {Component: BaseView}, + file_input: {Component: FileInputView}, number_with_scale: {Component: NumberWithScaleView}, monaco_input: {Component: MonacoViewCard}, text_content: undefined, diff --git a/src/lib/kit/i18n/en.json b/src/lib/kit/i18n/en.json index 442f57b4..997e364b 100644 --- a/src/lib/kit/i18n/en.json +++ b/src/lib/kit/i18n/en.json @@ -42,5 +42,7 @@ "label_error-zero-start": "Value must not start with a zero", "label_error-dot-end": "Value must not end with a dot", "label_delete": "Delete", - "button_cancel": "Close" + "button_cancel": "Close", + "button-upload_file": "Upload file", + "label-data_loaded": "Data uploaded" } diff --git a/src/lib/kit/i18n/ru.json b/src/lib/kit/i18n/ru.json index 0bcede14..a69cf391 100644 --- a/src/lib/kit/i18n/ru.json +++ b/src/lib/kit/i18n/ru.json @@ -42,5 +42,7 @@ "label_error-zero-start": "Значение не должно начинаться с нуля", "label_error-dot-end": "Значение не должно заканчиваться точкой", "label_delete": "Удалить", - "button_cancel": "Закрыть" + "button_cancel": "Закрыть", + "button-upload_file": "Загрузить файл", + "label-data_loaded": "Данные загружены" } diff --git a/src/stories/StringBase.stories.tsx b/src/stories/StringBase.stories.tsx index 60b5dd23..db7819a3 100644 --- a/src/stories/StringBase.stories.tsx +++ b/src/stories/StringBase.stories.tsx @@ -26,6 +26,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.monacoParams', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringFileInput.stories.tsx b/src/stories/StringFileInput.stories.tsx new file mode 100644 index 00000000..9ae42cc9 --- /dev/null +++ b/src/stories/StringFileInput.stories.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import {ComponentStory} from '@storybook/react'; + +import {FileInput as FileInputBase, SpecTypes, StringSpec} from '../lib'; + +import {InputPreview} from './components'; + +export default { + title: 'String/FileInput', + component: FileInputBase, +}; + +const baseSpec: StringSpec = { + type: SpecTypes.String, + viewSpec: { + type: 'file_input', + layout: 'row', + layoutTitle: 'File Input', + }, +}; + +const excludeOptions = [ + 'maximum', + 'minimum', + 'format', + 'enum', + 'description', + 'viewSpec.type', + 'viewSpec.sizeParams', + 'viewSpec.monacoParams', + 'viewSpec.themeLabel', + 'viewSpec.placeholder', + 'viewSpec.layoutOpen', +]; + +const template = (spec: StringSpec = baseSpec) => { + const Template: ComponentStory = (__, {viewMode}) => ( + + ); + + return Template; +}; + +export const FileInput = template(); diff --git a/src/stories/StringMonaco.stories.tsx b/src/stories/StringMonaco.stories.tsx index e8a6c8f8..b7766d16 100644 --- a/src/stories/StringMonaco.stories.tsx +++ b/src/stories/StringMonaco.stories.tsx @@ -32,6 +32,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.placeholder', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringNumberWithScale.stories.tsx b/src/stories/StringNumberWithScale.stories.tsx index 92d54df8..3cd70a66 100644 --- a/src/stories/StringNumberWithScale.stories.tsx +++ b/src/stories/StringNumberWithScale.stories.tsx @@ -43,6 +43,7 @@ const excludeOptions = [ 'viewSpec.type', 'viewSpec.monacoParams', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringPassword.stories.tsx b/src/stories/StringPassword.stories.tsx index f16a995d..9c833b7e 100644 --- a/src/stories/StringPassword.stories.tsx +++ b/src/stories/StringPassword.stories.tsx @@ -31,6 +31,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.monacoParams', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringSelect.stories.tsx b/src/stories/StringSelect.stories.tsx index 8aa80651..0f6fb858 100644 --- a/src/stories/StringSelect.stories.tsx +++ b/src/stories/StringSelect.stories.tsx @@ -36,6 +36,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.monacoParams', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringTextArea.stories.tsx b/src/stories/StringTextArea.stories.tsx index 837a6324..7518bb1b 100644 --- a/src/stories/StringTextArea.stories.tsx +++ b/src/stories/StringTextArea.stories.tsx @@ -31,6 +31,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.monacoParams', 'viewSpec.themeLabel', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/StringTextContent.stories.tsx b/src/stories/StringTextContent.stories.tsx index b6107849..4e0fd2ea 100644 --- a/src/stories/StringTextContent.stories.tsx +++ b/src/stories/StringTextContent.stories.tsx @@ -36,6 +36,7 @@ const excludeOptions = [ 'viewSpec.sizeParams', 'viewSpec.monacoParams', 'viewSpec.placeholder', + 'viewSpec.fileInput', ]; const template = (spec: StringSpec = baseSpec) => { diff --git a/src/stories/components/InputPreview/constants.ts b/src/stories/components/InputPreview/constants.ts index e8007ac4..c8fbd826 100644 --- a/src/stories/components/InputPreview/constants.ts +++ b/src/stories/components/InputPreview/constants.ts @@ -288,6 +288,34 @@ const order: ArraySpec = { viewSpec: {type: 'base', layout: 'accordeon', layoutTitle: 'Order'}, }; +const fileInput: ObjectSpec = { + type: SpecTypes.Object, + properties: { + accept: { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Accept'}, + }, + readAsMethod: { + type: SpecTypes.String, + enum: ['―', 'readAsArrayBuffer', 'readAsBinaryString', 'readAsDataURL', 'readAsText'], + viewSpec: {type: 'select', layout: 'row', layoutTitle: 'Read As Method'}, + }, + ignoreText: { + type: SpecTypes.Boolean, + viewSpec: { + type: 'base', + layout: 'row', + layoutTitle: 'Ignore text', + }, + }, + }, + viewSpec: { + type: 'base', + layout: 'accordeon', + layoutTitle: 'File Input', + }, +}; + export const getArrayOptions = (): ObjectSpec => ({ type: SpecTypes.Object, required: true, @@ -465,6 +493,7 @@ export const getStringOptions = (): ObjectSpec => ({ monacoParams, placeholder, themeLabel, + fileInput, }, [ 'disabled', @@ -477,6 +506,7 @@ export const getStringOptions = (): ObjectSpec => ({ 'monacoParams', 'placeholder', 'themeLabel', + 'fileInput', ], ), },