Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: file picker component #1072

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 147 additions & 107 deletions src/components/FilePicker/index.jsx
Original file line number Diff line number Diff line change
@@ -1,129 +1,169 @@
import classNames from 'classnames';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import React, { useState, useRef } from 'react';
import Button from 'react-bootstrap/lib/Button';
import './styles.scss';

const baseClass = 'filepicker-component';
const baseClass = 'aui--filepicker-component';

class FilePickerComponent extends React.PureComponent {
static propTypes = {
/**
* determines if the filePicker is disabled
*/
disabled: PropTypes.bool,
/**
* data-test-selector of the filePicker
*/
dts: PropTypes.string,
/**
* determines what file types the user can pick from the file input dialog box
*/
filter: PropTypes.string,
/**
* determines if the filePicker is highlighted or not
*/
isHighlighted: PropTypes.bool,
/**
* the label to be displayed
*/
label: PropTypes.string,
/**
* function called when onRemove event is fired
*/
onRemove: PropTypes.func,
/**
* function called when onSelect event is fired
*/
onSelect: PropTypes.func.isRequired,
/**
* determines the placeholder when no date is selected
*/
placeholder: PropTypes.string,
};

static defaultProps = {
isHighlighted: false,
label: 'Select',
placeholder: 'No file selected',
disabled: false,
};

constructor(props) {
super(props);

this.fileInput = React.createRef();
}
const FilePickerComponent = ({
disabled,
dts,
filter,
isHighlighted,
label,
onRemove,
onSelect,
onClick,
onChange,
value,
placeholder,
}) => {
const [isFileSelected, setIsFileSelected] = useState(false);
const [fileName, setFileName] = useState('');
const fileInput = useRef();

state = {
isFileSelected: false,
fileName: '',
};
const selectFile = event => {
if (!isFileSelected) {
const file = event.target.files[0];
setIsFileSelected(true);
if (_.isFunction(onChange)) {
onChange(file.name);
Copy link
Contributor

@lteacher lteacher Sep 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should probably provide the actual file for the onChange instead of just the name and allow the external component to put the file's name into the value. In those scenarios they would also not need to use onSelect since they have the power to do whatever and they also have the file itself to manage rather than it being hidden in the component.

}
setFileName(file.name);

onChange = changeEvent => {
if (!this.state.isFileSelected) {
this.setState({ isFileSelected: true, fileName: changeEvent.target.files[0].name });
this.props.onSelect(changeEvent.target.files[0]);
onSelect(file);
}
};

onUploadBtnClick = () => {
this.fileInput.current.click();
const onUploadBtnClick = () => {
fileInput.current.click();
};

removeFile = () => {
if (this.state.isFileSelected) {
this.fileInput.current.value = null;
this.setState({ isFileSelected: false, fileName: '' });
if (this.props.onRemove) {
this.props.onRemove();
const removeFile = () => {
if (isFileSelected || !_.isEmpty(value)) {
fileInput.current.value = null;
setIsFileSelected(false);
if (_.isFunction(onChange)) {
onChange('');
}
setFileName('');

if (_.isFunction(onRemove)) {
onRemove();
Comment on lines +48 to +54
Copy link
Contributor

@knilink knilink Sep 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite sure about this. Triggering multiple handlers here seems to be a little bit confusing to me.
IMO should only keep either onChange('') or onRemove(), otherwise there will be multiple ways of doing the same thing.

the usage should be either

// handle remove with onChange('')
handleChange = (value) => {
  if(value==='') {/* handle remove */}
}

or

// setValue('') with onRemove()
handleRemove = () => setValue('')

}
}
};

render() {
const mainClass = classNames({ [`${baseClass}-highlight`]: this.props.isHighlighted }, baseClass, 'input-group');
const { isFileSelected, fileName } = this.state;
const clickFile = () => {
if (_.isFunction(onClick)) {
onClick();
}
};

if (value && !onChange)
console.warn(
'Failed prop type: You have provided a `value` prop to FilePicker Component without an `onChange` handler. This will render a read-only field.'
);

return (
<div data-testid="file-picker-wrapper" className={mainClass}>
<input
data-testid="file-picker-form-control"
className="form-control"
type="text"
disabled
placeholder={this.props.placeholder}
readOnly="readonly"
value={fileName}
title={fileName}
/>
<div className="input-group-btn">
{isFileSelected ? (
<Button data-testid="file-picker-remove-button" className="remove-file" onClick={this.removeFile}>
×
</Button>
) : null}
<Button
data-testid="file-picker-input-button"
className="btn-inverse"
onClick={this.onUploadBtnClick}
disabled={this.props.disabled || isFileSelected}
>
<span data-testid="file-picker-input-button-label">{this.props.label}</span>
<input
data-testid="file-picker-input-button-input"
className="file-input"
ref={this.fileInput}
type="file"
onChange={this.onChange}
accept={this.props.filter}
data-test-selector={this.props.dts}
/>
const hasFileName = _.isString(value) ? value !== '' : isFileSelected;

return (
<div
className={classNames({ [`${baseClass}-highlight`]: isHighlighted }, baseClass, 'input-group')}
data-test-selector={dts}
data-testid="file-picker-wrapper"
>
<input
data-testid="file-picker-form-control"
className={classNames('form-control', { clickable: _.isFunction(onClick) })}
type="text"
readOnly
placeholder={placeholder}
value={value || fileName}
title={value || fileName}
onClick={clickFile}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@superpower1 @minhe86 can you put dts in this input as well, thank you.

Copy link
Contributor

@lightbringer1991 lightbringer1991 Sep 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something like data-test-selector={`${dts}-form-control`}, if dts exists.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will put dts to the wrapper div instead

<div className="input-group-btn">
{hasFileName ? (
<Button data-testid="file-picker-remove-button" className="remove-file" onClick={removeFile}>
×
</Button>
</div>
) : null}
<Button
data-testid="file-picker-input-button"
className="btn-inverse select-file"
onClick={onUploadBtnClick}
disabled={disabled || hasFileName}
>
<span data-testid="file-picker-input-button-label">{label}</span>
<input
data-testid="file-picker-input-button-input"
className="file-input"
ref={fileInput}
type="file"
onChange={selectFile}
accept={filter}
/>
</Button>
</div>
);
}
}
</div>
);
};

FilePickerComponent.propTypes = {
/**
* determines if the filePicker is disabled
*/
disabled: PropTypes.bool,
/**
* data-test-selector of the filePicker
*/
dts: PropTypes.string,
/**
* determines what file types the user can pick from the file input dialog box
*/
filter: PropTypes.string,
/**
* determines if the filePicker is highlighted or not
*/
isHighlighted: PropTypes.bool,
/**
* label on button
*/
label: PropTypes.string,
/**
* function called when onRemove event is fired
*/
onRemove: PropTypes.func,
/**
* function called when onSelect event is fired
*/
onSelect: PropTypes.func.isRequired,
/**
* function called when user click the file name
*/
onClick: PropTypes.func,
/**
* function called when the file name changes
*/
onChange: PropTypes.func,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't exactly recollect the discussion, however, didn't we decide to cut down the handler functions.
do we need onSelect, onClick, onChange, onRemove for this picker

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we were planning to retain the onSelect for scenarios where the value is handled by the component

/**
* file name on input
*/
value: PropTypes.string,
/**
* determines the placeholder when no date is selected
*/
placeholder: PropTypes.string,
};

FilePickerComponent.defaultProps = {
isHighlighted: false,
label: 'Select',
placeholder: 'No file selected',
disabled: false,
};

export default FilePickerComponent;
77 changes: 67 additions & 10 deletions src/components/FilePicker/index.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ afterEach(cleanup);

describe('<FilePicker />', () => {
it('should render with defaults', () => {
const { getByTestId } = render(<FilePickerComponent onSelect={jest.fn()} />);
expect(getByTestId('file-picker-wrapper')).toHaveClass('filepicker-component input-group');
const { getByTestId } = render(<FilePickerComponent onSelect={jest.fn()} dts="test-file-picker-input" />);
expect(getByTestId('file-picker-wrapper')).toHaveClass('aui--filepicker-component input-group');
expect(getByTestId('file-picker-wrapper')).toHaveAttribute('data-test-selector', 'test-file-picker-input');

expect(getByTestId('file-picker-form-control')).toHaveClass('form-control');
expect(getByTestId('file-picker-form-control')).toHaveAttribute('placeholder', 'No file selected');
Expand All @@ -20,21 +21,14 @@ describe('<FilePicker />', () => {

it('should show remove button and call `onSelect` when file selected', () => {
const onSelect = jest.fn();
const { getByTestId, queryAllByTestId } = render(
<FilePickerComponent onSelect={onSelect} dts="test-file-picker-input" />
);
const { getByTestId, queryAllByTestId } = render(<FilePickerComponent onSelect={onSelect} />);

expect(getByTestId('file-picker-form-control')).toHaveAttribute('title', '');
expect(getByTestId('file-picker-form-control')).toHaveAttribute('placeholder', 'No file selected');

expect(queryAllByTestId('file-picker-remove-button')).toHaveLength(0);
expect(getByTestId('file-picker-input-button')).toBeEnabled();

expect(getByTestId('file-picker-input-button-input')).toHaveAttribute(
'data-test-selector',
'test-file-picker-input'
);

fireEvent.change(getByTestId('file-picker-input-button-input'), { target: { files: [{ name: 'selected file' }] } });
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith({ name: 'selected file' });
Expand Down Expand Up @@ -95,4 +89,67 @@ describe('<FilePicker />', () => {
expect(getByTestId('file-picker-form-control')).toHaveAttribute('title', '');
expect(onRemove).toHaveBeenCalledTimes(1);
});

it('should show value as file name, show remove button and disable input button if value is provided', () => {
const { getByTestId, queryAllByTestId } = render(
<FilePickerComponent onSelect={jest.fn()} value="custom_file_name" onChange={jest.fn()} />
);
expect(getByTestId('file-picker-form-control')).toHaveAttribute('value', 'custom_file_name');
expect(getByTestId('file-picker-form-control')).toHaveAttribute('title', 'custom_file_name');
expect(queryAllByTestId('file-picker-remove-button')).toHaveLength(1);
expect(getByTestId('file-picker-input-button')).toBeDisabled();
});

it('should remove file name, hide remove button and enable input button if value is set to empty string', () => {
const { getByTestId, queryAllByTestId, rerender } = render(
<FilePickerComponent onSelect={jest.fn()} value="custom_file_name" onChange={jest.fn()} />
);
expect(getByTestId('file-picker-form-control')).toHaveAttribute('value', 'custom_file_name');

rerender(<FilePickerComponent onSelect={jest.fn()} value="" />);
expect(getByTestId('file-picker-form-control')).toHaveAttribute('value', '');
expect(queryAllByTestId('file-picker-remove-button')).toHaveLength(0);
expect(getByTestId('file-picker-input-button')).toBeEnabled();
});

it('should show warning if value is provided but onChange is not provided', () => {
console.warn = jest.fn();

render(<FilePickerComponent onSelect={jest.fn()} value="custom_file_name" />);

expect(console.warn).toHaveBeenCalledWith(
'Failed prop type: You have provided a `value` prop to FilePicker Component without an `onChange` handler. This will render a read-only field.'
);
});

it('should call `onChange` with the file name when a file is selected', () => {
const onChange = jest.fn();
const { getByTestId } = render(
<FilePickerComponent onSelect={jest.fn()} value="custom_file_name" onChange={onChange} />
);
fireEvent.change(getByTestId('file-picker-input-button-input'), {
target: { files: [{ name: 'selected_file_name' }] },
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('selected_file_name');
});

it('should call `onChange` with the empty string when a file is removed', () => {
const onChange = jest.fn();
const { getByTestId } = render(
<FilePickerComponent onSelect={jest.fn()} value="custom_file_name" onChange={onChange} />
);
fireEvent.click(getByTestId('file-picker-remove-button'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('');
});

it('should call `onClick` when file name is clicked', () => {
const onClick = jest.fn();
const { getByTestId } = render(
<FilePickerComponent onSelect={jest.fn()} onClick={onClick} value="custom_file_name" onChange={jest.fn()} />
);
fireEvent.click(getByTestId('file-picker-form-control'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
Loading