diff --git a/lib/editor/components/timetable/EditableCell.js b/lib/editor/components/timetable/EditableCell.js index 2f23f3cb2..05a815071 100644 --- a/lib/editor/components/timetable/EditableCell.js +++ b/lib/editor/components/timetable/EditableCell.js @@ -1,11 +1,11 @@ // @flow -import React, {Component} from 'react' -import moment from 'moment' +// $FlowFixMe +import React, {useEffect, useState} from 'react' import * as tripActions from '../../actions/trip' import {secondsAfterMidnightToHHMM} from '../../../common/util/gtfs' -import { isTimeFormat } from '../../util/timetable' +import { isTimeFormat, parseCellValue } from '../../util/timetable' import type {TimetableColumn} from '../../../types' import type {EditorValidationIssue} from '../../util/validation' @@ -55,120 +55,123 @@ const renderCell = ( /** * A component to handle the editing of a cell in a timetable editor */ -export default class EditableCell extends Component { - componentWillMount () { - this.setState({ - isEditing: this.props.isEditing, - isFocused: false, - edited: false, - data: this.props.data, - originalData: this.props.data - }) - } - - cellInput = null +function EditableCell (props: Props) { + const [state, setState] = useState({ + isEditing: props.isEditing, + isFocused: false, + edited: false, + data: props.data, + originalData: props.data + }) /** * The component may receive data from a save event or * editing can be changed by a change in the activeCell in the TimetableGrid */ - componentWillReceiveProps (nextProps: Props) { - if (this.props.data !== nextProps.data) { - this.setState({data: nextProps.data}) + useEffect(() => { + setState((prevState) => ({ + ...prevState, + data: props.data + })) + if (state.isEditing !== props.isEditing) { + setState((prevState) => ({ + ...prevState, + isEditing: props.isEditing + })) } - if (this.state.isEditing !== nextProps.isEditing) { - this.setState({isEditing: nextProps.isEditing}) - } - } + }, [props.data, props.isEditing]) - cancel = () => { - this.setState({ + const cancel = () => { + setState((prevState) => ({ + ...prevState, isEditing: false, isFocused: false, - data: this.props.data - }) - this.props.onStopEditing() + data: props.data + })) + props.onStopEditing() } - beginEditing () { - const {columnIndex, rowIndex, setActiveCell} = this.props + const beginEditing = () => { + const {columnIndex, rowIndex, setActiveCell} = props setActiveCell(`${rowIndex}-${columnIndex}`) - this.setState({isEditing: true}) + setState((prevState) => ({ + ...prevState, + isEditing: true + })) } - handleClick = (evt: SyntheticInputEvent) => { - const {columnIndex, isFocused, onClick, rowIndex} = this.props - if (isFocused) this.beginEditing() - else onClick(rowIndex, columnIndex) + const handleClick = (evt) => { + const {columnIndex, isFocused, onClick, rowIndex} = props + if (isFocused) { + beginEditing() + } else { + onClick(rowIndex, columnIndex) + } } /** * Depending on the key pressed while focused on a cell, do some special things. */ - handleKeyDown = (evt: SyntheticKeyboardEvent) => { + const handleKeyDown = (evt) => { const { isFocused, offsetScrollCol, offsetScrollRow - } = this.props - const {isEditing} = this.state + } = props + const {isEditing, data, originalData} = state switch (evt.keyCode) { case 13: // Enter evt.preventDefault() - if (isFocused) { - this.beginEditing() - } - // handle shift - if (evt.shiftKey) { - this.save() + if (isFocused && isEditing && data !== originalData) { + beginEditing() + save() + break + // handle shift + } else if (evt.shiftKey) { offsetScrollRow(-1) - } else { - this.save() } + + cancel() break + case 9: // Tab - // save and advance to next cell if editing - this.save() - offsetScrollCol(evt.shiftKey ? -1 : 1) + // save and advance to next cell if editing evt.preventDefault() evt.stopPropagation() + if (isEditing && data !== originalData) { + save() + } + offsetScrollCol(evt.shiftKey ? -1 : 1) + cancel() break + case 27: // Esc - this.cancel() + cancel() break - case 39: // right - // cancel event propogation if cell is being edited - if (isEditing) { - evt.stopPropagation() - return - } - // update scroll position in TimetableGrid - offsetScrollCol(1) - return case 37: // left - // do nothing if cell is being edited + case 39: // right + // cancel event propogation if cell is being edited if (isEditing) { evt.stopPropagation() - return + } else { + // update scroll position in TimetableGrid + offsetScrollCol(evt.keyCode === 37 ? -1 : 1) } - - // update scroll position in TimetableGrid - offsetScrollCol(-1) - return - case 38: // up - this.save() break + + case 38: // Up case 40: // down - this.save() + save() break + default: return true } } - _onOuterKeyDown = (e: SyntheticKeyboardEvent) => { - const {offsetScrollCol} = this.props + const _onOuterKeyDown = (e) => { + const {offsetScrollCol} = props switch (e.keyCode) { case 9: // tab // update scroll position in TimetableGrid @@ -188,44 +191,53 @@ export default class EditableCell extends Component { } } - _handleSave = (value: any) => { - const { rowIndex, column, columnIndex, onChange, onStopEditing } = this.props - this.setState({isEditing: false, data: value, originalData: value}) + const _handleSave = (value) => { + const { rowIndex, column, columnIndex, onChange, onStopEditing } = props + setState((prevState) => ({ + ...prevState, + isEditing: false, + data: value, + originalData: value + })) onChange(value, rowIndex, column, columnIndex) onStopEditing() } - save () { - const {column} = this.props - const {data} = this.state - // for non-time rendering - if (column.type === 'TEXT') { - if (data !== this.state.originalData) this._handleSave(data) - else this.cancel() + const save = () => { + const {column} = props + const {data, originalData} = state + + if (column.type === 'TEXT' && data !== originalData) { + _handleSave(data) } else if (column.type === 'SECONDS') { - // Ensure that only a positive integer value can be set. const value = +data - this._handleSave(value) - } else { - if (typeof data !== 'string') return this.cancel() - const duration = moment.duration(data) - // $FlowFixMe: flow doesn't know about duration.isValid ensuring valueOf returns a number - const value = duration.isValid() && duration.valueOf() / 1000 // valueOf returns milliseconds - if (value !== false) this._handleSave(value) - else this.cancel() - } - } - handleBlur = () => { - this.save() + if (!isNaN(value) && value >= 0) { + _handleSave(value) + } else { + alert('Please enter a positive number.') + cancel() + } + } else if (isTimeFormat(column.type)) { + const parsedValue = parseCellValue(data, column) + + if (parsedValue !== null) { + _handleSave(parsedValue) + } else { + cancel() + } + } } - handleChange = (evt: SyntheticInputEvent) => { - this.setState({data: evt.target.value}) + const handleChange = (evt) => { + setState((prevState) => ({ + ...prevState, + data: evt.target.value + })) } - handlePaste = (evt: ClipboardEvent) => { - const {handlePastedRows, rowIndex, columnIndex} = this.props + const handlePaste = (evt: ClipboardEvent) => { + const {handlePastedRows, rowIndex, columnIndex} = props const {clipboardData} = evt if (!clipboardData) { console.warn('No clipboard data found.') @@ -238,91 +250,87 @@ export default class EditableCell extends Component { ? String.fromCharCode(13) : undefined const rows = text.split(rowDelimiter) - const rowsAndColumns = [] - // Split each row into columns - for (let i = 0; i < rows.length; i++) { - rowsAndColumns.push(rows[i].split(String.fromCharCode(9))) - } - + // Remove carriage return characters (/r) from rows to handle pasted data from Excel + const rowsAndColumns = rows.map(row => row.split(String.fromCharCode(9)).map(cellValue => cellValue.replace(/\r/g, ''))) if (rowsAndColumns.length > 1 || rowsAndColumns[0].length > 1) { - this.cancel() + cancel() handlePastedRows(rowsAndColumns, rowIndex, columnIndex) evt.preventDefault() } } - _onInputFocus = (evt: SyntheticInputEvent) => { + const _onInputFocus = (evt) => { evt.target.select() } - render () { - const {column, invalidData, isFocused, isSelected, lightText, placeholder, style} = this.props - const {data, edited, isEditing} = this.state - const rowCheckedColor = '#F3FAF6' - const focusedNotEditing = isFocused && !isEditing - const edgeDiff = isFocused ? 0 : 0.5 - const divStyle = { - paddingTop: `${3 + edgeDiff}px`, - paddingLeft: `${3 + edgeDiff}px`, - fontWeight: edited ? 'bold' : 'normal' - } - const cellStyle = { - backgroundColor: invalidData && !isEditing - ? 'pink' - : focusedNotEditing - ? '#F4F4F4' - : isEditing - ? '#fff' - : isSelected - ? rowCheckedColor - : '#fff', - border: invalidData && focusedNotEditing - ? '2px solid red' - : isFocused - ? `2px solid #66AFA2` - : '1px solid #ddd', - margin: `${-0.5 + edgeDiff}px`, - padding: `${-edgeDiff}px`, - // fontFamily: '"Courier New", Courier, monospace', - color: lightText ? '#aaa' : '#000', - ...style - } - const cellHtml = isEditing - ? - :
- {renderCell(column, data)} -
- return ( -
- {cellHtml} -
- ) - } + /* eslint-disable-next-line jsx-a11y/no-autofocus */ + autoFocus + defaultValue={renderCell(column, data)} + className='cell-input' + onBlur={cancel} + onChange={handleChange} + onFocus={_onInputFocus} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder || ''} + readOnly={!isEditing} + type='text' /> + :
+ {renderCell(column, data)} +
+ return ( +
+ {cellHtml} +
+ ) } + +export default EditableCell diff --git a/lib/editor/components/timetable/TimetableGrid.js b/lib/editor/components/timetable/TimetableGrid.js index 916105650..85c53bc04 100644 --- a/lib/editor/components/timetable/TimetableGrid.js +++ b/lib/editor/components/timetable/TimetableGrid.js @@ -10,8 +10,6 @@ import objectPath from 'object-path' import * as tripActions from '../../actions/trip' import {ENTITY} from '../../constants' -import HeaderCell from './HeaderCell' -import EditableCell from './EditableCell' import { getHeaderColumns, HEADER_GRID_STYLE, @@ -23,17 +21,19 @@ import { MAIN_GRID_WRAPPER_STYLE, OVERSCAN_COLUMN_COUNT, OVERSCAN_ROW_COUNT, - parseTime, + parseCellValue, ROW_HEIGHT, SCROLL_SIZE, TOP_LEFT_STYLE, WRAPPER_STYLE } from '../../util/timetable' - import type {Pattern, TimetableColumn} from '../../../types' import type {TimetableState} from '../../../types/reducers' import type {TripValidationIssues} from '../../selectors/timetable' +import EditableCell from './EditableCell' +import HeaderCell from './HeaderCell' + type Style = {[string]: number | string} type Props = { @@ -378,31 +378,44 @@ export default class TimetableGrid extends Component { } = this.props let activeRow = rowIndex let activeCol = colIndex - // iterate over rows in pasted selection + let errorShown = false + // Iterate over rows in pasted selection for (var i = 0; i < pastedRows.length; i++) { activeRow = rowIndex + i - // construct new row if it doesn't exist - if (typeof data[i + rowIndex] === 'undefined') { + // Construct new row if it doesn't exist + if (typeof data[activeRow] === 'undefined') { addNewRow() } - // iterate over number of columns in pasted selection + // Iterate over number of columns in pasted selection for (var j = 0; j < pastedRows[0].length; j++) { activeCol = colIndex + j - const path = `${rowIndex + i}.${columns[colIndex + j].key}` - const value = parseTime(pastedRows[i][j]) - updateCellValue({value, rowIndex: rowIndex + i, key: path}) - // if departure times are hidden, paste into adjacent time column - const adjacentPath = `${rowIndex + i}.${columns[colIndex + j + 2].key}` - if ( - hideDepartureTimes && - isTimeFormat(columns[colIndex + j].type) && - typeof objectPath.get(data, adjacentPath) !== 'undefined' - ) { - updateCellValue({value, rowIndex: rowIndex + i, key: adjacentPath}) + const col = columns[activeCol] + const path = `${activeRow}.${col.key}` + const originalValue = objectPath.get(data[activeRow], col.key) + const value = parseCellValue(pastedRows[i][j], col, errorShown) + if (value !== null) { + const finalValue = value + updateCellValue({ value: finalValue, rowIndex: activeRow, key: path }) + // If departure times are hidden, paste into adjacent time column + const adjacentPath = `${activeRow}.${columns[activeCol + 2].key}` + if ( + hideDepartureTimes && + isTimeFormat(col.type) && + typeof objectPath.get(data, adjacentPath) !== 'undefined' + ) { + updateCellValue({ value: finalValue, rowIndex: activeRow, key: adjacentPath }) + } + } else { + // If the value is rejected, keep the original value + errorShown = true + updateCellValue({ value: originalValue, rowIndex: activeRow, key: path }) } } } - setActiveCell(`${activeRow}-${activeCol}`) + // Prevent the active cell from being set to the last cell in the pasted selection + if (activeRow !== rowIndex + pastedRows.length - 1) { + setActiveCell(`${activeRow}-${activeCol}`) + } updateScroll(activeRow, activeCol) } diff --git a/lib/editor/components/timetable/TimetableHeader.js b/lib/editor/components/timetable/TimetableHeader.js index f688eca04..9d1efbe08 100644 --- a/lib/editor/components/timetable/TimetableHeader.js +++ b/lib/editor/components/timetable/TimetableHeader.js @@ -181,7 +181,7 @@ export default class TimetableHeader extends Component { bsStyle: 'primary', 'data-test-id': 'save-trip-button', children: , - disabled: edited.length === 0 || errorCount, + disabled: edited.length === 0 || errorCount > 0, onClick: this._onClickSave } }] diff --git a/lib/editor/util/timetable.js b/lib/editor/util/timetable.js index 7fc1bd5d2..7dcf8d97c 100644 --- a/lib/editor/util/timetable.js +++ b/lib/editor/util/timetable.js @@ -40,12 +40,21 @@ export function getHeaderColumns ( return columns.filter(c => c.type !== 'DEPARTURE_TIME') } -export function parseTime (timeString: string) { +/** +* Handles pasted data from clipboard (e.g. from CSV file) +* If departure/arrival time cell, pastes in time format, otherwise returns string as is + */ +export function parseCellValue (timeString: string, col: TimetableColumn, errorShown?: boolean) { const date = moment().startOf('day').format('YYYY-MM-DD') - return moment(date + 'T' + timeString, TIMETABLE_FORMATS).diff( - date, - 'seconds' - ) + const parsedDate = moment(date + 'T' + timeString, TIMETABLE_FORMATS, true) + if (isTimeFormat(col.type)) { + if (!parsedDate.isValid()) { + if (!errorShown) alert('Please enter a valid time format') + return null + } + return moment(date + 'T' + timeString, TIMETABLE_FORMATS).diff(date, 'seconds') + } + return timeString } export const LEFT_COLUMN_WIDTH = 30