mirror of https://github.com/portainer/portainer
feat(react): add FileUploadField and FileUploadForm components [EE-2336] (#6350)
parent
07e7fbd270
commit
8dbb802fb1
|
@ -213,6 +213,11 @@ export default class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
formIsInvalid() {
|
formIsInvalid() {
|
||||||
return this.form.$invalid || !this.formValues.Groups.length || (['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent);
|
return (
|
||||||
|
this.form.$invalid ||
|
||||||
|
!this.formValues.Groups.length ||
|
||||||
|
(['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent) ||
|
||||||
|
('upload' === this.state.Method && !this.formValues.StackFile)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,9 @@ class DockerComposeFormController {
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeFile(value) {
|
onChangeFile(value) {
|
||||||
this.formValues.StackFile = value;
|
return this.$async(async () => {
|
||||||
|
this.formValues.StackFile = value;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async $onInit() {
|
async $onInit() {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
class KubeManifestFormController {
|
class KubeManifestFormController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor() {
|
constructor($async) {
|
||||||
|
Object.assign(this, { $async });
|
||||||
|
|
||||||
this.methodOptions = [
|
this.methodOptions = [
|
||||||
{ id: 'method_editor', icon: 'fa fa-edit', label: 'Web editor', description: 'Use our Web editor', value: 'editor' },
|
{ id: 'method_editor', icon: 'fa fa-edit', label: 'Web editor', description: 'Use our Web editor', value: 'editor' },
|
||||||
{ id: 'method_upload', icon: 'fa fa-upload', label: 'Upload', description: 'Upload from your computer', value: 'upload' },
|
{ id: 'method_upload', icon: 'fa fa-upload', label: 'Upload', description: 'Upload from your computer', value: 'upload' },
|
||||||
|
@ -23,7 +25,9 @@ class KubeManifestFormController {
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeFile(value) {
|
onChangeFile(value) {
|
||||||
this.formValues.StackFile = value;
|
return this.$async(async () => {
|
||||||
|
this.formValues.StackFile = value;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeMethod(method) {
|
onChangeMethod(method) {
|
||||||
|
|
|
@ -46,7 +46,9 @@ class KubeCreateCustomTemplateViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeFile(file) {
|
onChangeFile(file) {
|
||||||
this.formValues.File = file;
|
return this.$async(async () => {
|
||||||
|
this.formValues.File = file;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCustomTemplate() {
|
async createCustomTemplate() {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
.file-input {
|
||||||
|
display: none !important;
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { FileUploadField } from './FileUploadField';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: FileUploadField,
|
||||||
|
title: 'Components/Buttons/FileUploadField',
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Example({ title }: Args) {
|
||||||
|
const [value, setValue] = useState<File>();
|
||||||
|
function onChange(value: File) {
|
||||||
|
if (value) {
|
||||||
|
setValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FileUploadField onChange={onChange} value={value} title={title} />;
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { fireEvent, render } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { FileUploadField } from './FileUploadField';
|
||||||
|
|
||||||
|
test('render should make the file button clickable and fire onChange event after click', async () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const { findByText, findByLabelText } = render(
|
||||||
|
<FileUploadField title="test button" onChange={onClick} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = await findByText('test button');
|
||||||
|
expect(button).toBeVisible();
|
||||||
|
|
||||||
|
const input = await findByLabelText('file-input');
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
const mockFile = new File([], 'file.txt');
|
||||||
|
if (input) {
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: { files: [mockFile] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(onClick).toHaveBeenCalledWith(mockFile);
|
||||||
|
});
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { ChangeEvent, createRef } from 'react';
|
||||||
|
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { Button } from '@/portainer/components/Button';
|
||||||
|
|
||||||
|
import styles from './FileUploadField.module.css';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onChange(value: File): void;
|
||||||
|
value?: File;
|
||||||
|
title?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadField({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
title = 'Select a file',
|
||||||
|
required = false,
|
||||||
|
}: Props) {
|
||||||
|
const fileRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-upload-field">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
required={required}
|
||||||
|
className={styles.fileInput}
|
||||||
|
onChange={changeHandler}
|
||||||
|
aria-label="file-input"
|
||||||
|
/>
|
||||||
|
<Button size="small" color="primary" onClick={handleButtonClick}>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="space-left">
|
||||||
|
{value ? (
|
||||||
|
value.name
|
||||||
|
) : (
|
||||||
|
<i className="fa fa-times red-icon" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleButtonClick() {
|
||||||
|
if (fileRef && fileRef.current) {
|
||||||
|
fileRef.current.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeHandler(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (event.target && event.target.files && event.target.files.length > 0) {
|
||||||
|
onChange(event.target.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUploadFieldAngular = r2a(FileUploadField, [
|
||||||
|
'onChange',
|
||||||
|
'value',
|
||||||
|
'title',
|
||||||
|
'required',
|
||||||
|
]);
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { FileUploadForm } from './FileUploadForm';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: FileUploadForm,
|
||||||
|
title: 'Components/Form/FileUploadForm',
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Example({ title }: Args) {
|
||||||
|
const [value, setValue] = useState<File>();
|
||||||
|
function onChange(value: File) {
|
||||||
|
if (value) {
|
||||||
|
setValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-horizontal">
|
||||||
|
<FileUploadForm
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
title={title}
|
||||||
|
description={
|
||||||
|
<span>You can upload a Compose file from your computer.</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { render } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { FileUploadForm } from './FileUploadForm';
|
||||||
|
|
||||||
|
test('render should include description', async () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const { findByText } = render(
|
||||||
|
<FileUploadForm
|
||||||
|
title="test button"
|
||||||
|
onChange={onClick}
|
||||||
|
description={<span>test description</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = await findByText('test button');
|
||||||
|
expect(button).toBeVisible();
|
||||||
|
|
||||||
|
const description = await findByText('test description');
|
||||||
|
expect(description).toBeVisible();
|
||||||
|
});
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
|
import { FileUploadField } from '@/portainer/components/form-components/FileUpload/FileUploadField';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onChange(value: unknown): void;
|
||||||
|
value?: File;
|
||||||
|
title?: string;
|
||||||
|
required?: boolean;
|
||||||
|
description: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadForm({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
title = 'Select a file',
|
||||||
|
required = false,
|
||||||
|
description,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
return (
|
||||||
|
<div className="file-upload-form">
|
||||||
|
<FormSectionTitle>Upload</FormSectionTitle>
|
||||||
|
<div className="form-group">
|
||||||
|
<span className="col-sm-12 text-muted small">{description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<FileUploadField
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
title={title}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { FileUploadField, FileUploadFieldAngular } from './FileUploadField';
|
||||||
|
export { FileUploadForm } from './FileUploadForm';
|
|
@ -1,19 +1,11 @@
|
||||||
<ng-form class="file-upload-form" name="$ctrl.fileUploadForm">
|
<ng-form class="file-upload-form" name="$ctrl.fileUploadForm">
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title"> Upload </div>
|
||||||
Upload
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
|
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button type="button" class="btn btn-sm btn-primary" ngf-select="$ctrl.onChange($file)" ng-model="$ctrl.file" ng-required="$ctrl.ngRequired" name="file">
|
<file-upload-field on-change="($ctrl.onChange)" required="($ctrl.ngRequired)" value="($ctrl.file)"></file-upload-field>
|
||||||
Select file
|
|
||||||
</button>
|
|
||||||
<span class="space-left">
|
|
||||||
{{ $ctrl.file.name }}
|
|
||||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.file" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-form>
|
</ng-form>
|
||||||
|
|
|
@ -5,8 +5,11 @@ import { fileUploadForm } from './file-upload-form';
|
||||||
|
|
||||||
import { SwitchFieldAngular } from './SwitchField';
|
import { SwitchFieldAngular } from './SwitchField';
|
||||||
|
|
||||||
|
import { FileUploadFieldAngular } from './FileUpload';
|
||||||
|
|
||||||
export default angular
|
export default angular
|
||||||
.module('portainer.app.components.form', [])
|
.module('portainer.app.components.form', [])
|
||||||
.component('webEditorForm', webEditorForm)
|
.component('webEditorForm', webEditorForm)
|
||||||
.component('fileUploadForm', fileUploadForm)
|
.component('fileUploadForm', fileUploadForm)
|
||||||
|
.component('fileUploadField', FileUploadFieldAngular)
|
||||||
.component('porSwitchField', SwitchFieldAngular).name;
|
.component('porSwitchField', SwitchFieldAngular).name;
|
||||||
|
|
Loading…
Reference in New Issue