Repository: aurora Updated Branches: refs/heads/master ec640117c -> 5b91150fd
Add sorting and filtering controls for TaskList Reviewed at https://reviews.apache.org/r/63188/ Project: http://git-wip-us.apache.org/repos/asf/aurora/repo Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/5b91150f Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/5b91150f Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/5b91150f Branch: refs/heads/master Commit: 5b91150fd0668c23b178d80516427763764ac2d3 Parents: ec64011 Author: David McLaughlin <[email protected]> Authored: Mon Oct 23 12:48:20 2017 -0700 Committer: David McLaughlin <[email protected]> Committed: Mon Oct 23 12:48:20 2017 -0700 ---------------------------------------------------------------------- ui/src/main/js/components/JobHistory.js | 2 +- ui/src/main/js/components/TaskList.js | 132 +++++++++++++++++-- .../js/components/__tests__/JobHistory-test.js | 2 +- .../js/components/__tests__/TaskList-test.js | 82 +++++++++++- ui/src/main/js/utils/Common.js | 8 ++ ui/src/main/js/utils/__tests__/Common-test.js | 19 +++ ui/src/main/sass/components/_task-list.scss | 28 ++++ 7 files changed, 259 insertions(+), 14 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/components/JobHistory.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/JobHistory.js b/ui/src/main/js/components/JobHistory.js index 9f00a7b..74f6fcb 100644 --- a/ui/src/main/js/components/JobHistory.js +++ b/ui/src/main/js/components/JobHistory.js @@ -10,6 +10,6 @@ import { getLastEventTime, isActive } from 'utils/Task'; export default function ({ tasks }) { const terminalTasks = sort(tasks.filter((t) => !isActive(t)), (t) => getLastEventTime(t), true); return (<Tab id='history' name={`Job History (${terminalTasks.length})`}> - <PanelGroup><TaskList tasks={terminalTasks} /></PanelGroup> + <PanelGroup><TaskList sortBy='latest' tasks={terminalTasks} /></PanelGroup> </Tab>); } http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/components/TaskList.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/TaskList.js b/ui/src/main/js/components/TaskList.js index dd34c62..4a4b8d3 100644 --- a/ui/src/main/js/components/TaskList.js +++ b/ui/src/main/js/components/TaskList.js @@ -1,10 +1,12 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import Icon from 'components/Icon'; import Pagination from 'components/Pagination'; import { RelativeTime } from 'components/Time'; import TaskStateMachine from 'components/TaskStateMachine'; +import { pluralize } from 'utils/Common'; import { getClassForScheduleStatus, getDuration, getLastEventTime, isActive } from 'utils/Task'; import { SCHEDULE_STATUS } from 'utils/Thrift'; @@ -64,15 +66,125 @@ export class TaskListItem extends React.Component { } } -export default function TaskList({ tasks }) { - return (<div className='task-list'> - <table className='psuedo-table'> - <Pagination - data={tasks} - hideIfSinglePage - isTable - numberPerPage={25} - renderer={(t) => <TaskListItem key={t.assignedTask.taskId} task={t} />} /> - </table> +// VisibleForTesting +export function searchTask(task, userQuery) { + const query = userQuery.toLowerCase(); + return (task.assignedTask.instanceId.toString().startsWith(query) || + (task.assignedTask.slaveHost && task.assignedTask.slaveHost.toLowerCase().includes(query)) || + SCHEDULE_STATUS[task.status].toLowerCase().startsWith(query)); +} + +export function TaskListFilter({ numberPerPage, onChange, tasks }) { + if (tasks.length > numberPerPage) { + return (<div className='table-input-wrapper'> + <Icon name='search' /> + <input + autoFocus + onChange={(e) => onChange(e)} + placeholder='Search tasks by instance-id, host or current status' + type='text' /> + </div>); + } + return null; +} + +export function TaskListStatus({ status }) { + return [ + <span className={`img-circle ${getClassForScheduleStatus(ScheduleStatus[status])}`} />, + <span>{status}</span> + ]; +} + +export function TaskListStatusFilter({ onClick, tasks }) { + const statuses = Object.keys(tasks.reduce((seen, task) => { + seen[SCHEDULE_STATUS[task.status]] = true; + return seen; + }, {})); + + if (statuses.length <= 1) { + return (<div> + {pluralize(tasks, 'One task is ', `All ${tasks.length} tasks are `)} + <TaskListStatus status={statuses[0]} /> + </div>); + } + + return (<ul className='task-list-status-filter'> + <li>Filter by:</li> + <li onClick={(e) => onClick(null)}>all</li> + {statuses.map((status) => (<li key={status} onClick={(e) => onClick(status)}> + <TaskListStatus status={status} /> + </li>))} + </ul>); +} + +export function TaskListControls({ currentSort, onFilter, onSort, tasks }) { + return (<div className='task-list-controls'> + <ul className='task-list-main-sort'> + <li>Sort by:</li> + <li className={currentSort === 'default' ? 'active' : ''} onClick={(e) => onSort('default')}> + instance + </li> + <li className={currentSort === 'latest' ? 'active' : ''} onClick={(e) => onSort('latest')}> + updated + </li> + </ul> + <TaskListStatusFilter onClick={onFilter} tasks={tasks} /> </div>); } + +export default class TaskList extends React.Component { + constructor(props) { + super(props); + this.state = { + filter: props.filter, + reverseSort: props.reverse || false, + sortBy: props.sortBy || 'default' + }; + } + + setFilter(filter) { + this.setState({filter}); + } + + setSort(sortBy) { + if (sortBy === this.state.sortBy) { + this.setState({reverseSort: !this.state.reverseSort}); + } else { + this.setState({sortBy}); + } + } + + render() { + const that = this; + const tasksPerPage = 25; + const filterFn = (t) => that.state.filter ? searchTask(t, that.state.filter) : true; + const sortFn = this.state.sortBy === 'latest' + ? (t) => getLastEventTime(t) * -1 + : (t) => t.assignedTask.instanceId; + + return (<div> + <TaskListFilter + numberPerPage={tasksPerPage} + onChange={(e) => that.setFilter(e.target.value)} + tasks={this.props.tasks} /> + <TaskListControls + currentSort={this.state.sortBy} + onFilter={(query) => that.setFilter(query)} + onSort={(key) => that.setSort(key)} + tasks={this.props.tasks} /> + <div className='task-list'> + <table className='psuedo-table'> + <Pagination + data={this.props.tasks} + filter={filterFn} + hideIfSinglePage + isTable + numberPerPage={tasksPerPage} + renderer={(t) => <TaskListItem key={t.assignedTask.taskId} task={t} />} + reverseSort={this.state.reverseSort} + sortBy={sortFn} /> + </table> + </div> + </div>); + } +} http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/components/__tests__/JobHistory-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/JobHistory-test.js b/ui/src/main/js/components/__tests__/JobHistory-test.js index 13f7ecc..7a916d1 100644 --- a/ui/src/main/js/components/__tests__/JobHistory-test.js +++ b/ui/src/main/js/components/__tests__/JobHistory-test.js @@ -13,6 +13,6 @@ describe('JobHistory', () => { ScheduledTaskBuilder.status(ScheduleStatus.FINISHED).build() ]; const el = shallow(JobHistory({tasks})); - expect(el.contains(<TaskList tasks={[tasks[1]]} />)).toBe(true); + expect(el.contains(<TaskList sortBy='latest' tasks={[tasks[1]]} />)).toBe(true); }); }); http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/components/__tests__/TaskList-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/components/__tests__/TaskList-test.js b/ui/src/main/js/components/__tests__/TaskList-test.js index ae74ff4..c222b61 100644 --- a/ui/src/main/js/components/__tests__/TaskList-test.js +++ b/ui/src/main/js/components/__tests__/TaskList-test.js @@ -1,10 +1,16 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { TaskListItem } from '../TaskList'; +import { + TaskListControls, + TaskListStatusFilter, + TaskListItem, + TaskListStatus, + searchTask +} from '../TaskList'; import TaskStateMachine from '../TaskStateMachine'; -import { ScheduledTaskBuilder } from 'test-utils/TaskBuilders'; +import { AssignedTaskBuilder, ScheduledTaskBuilder } from 'test-utils/TaskBuilders'; describe('TaskListItem', () => { it('Should not show any state machine element by default', () => { @@ -20,3 +26,75 @@ describe('TaskListItem', () => { expect(el.find('tr.expanded').length).toBe(1); }); }); + +describe('TaskListControls', () => { + it('Should attach active to default list element', () => { + const el = shallow(<TaskListControls + currentSort='default' + onFilter={() => {}} + onSort={() => {}} + tasks={[ScheduledTaskBuilder.build()]} />); + + expect(el.find('li.active').text()).toContain('instance'); + }); + + it('Should attach active to latest list element', () => { + const el = shallow(<TaskListControls + currentSort='latest' + onFilter={() => {}} + onSort={() => {}} + tasks={[ScheduledTaskBuilder.build()]} />); + + expect(el.find('li.active').text()).toContain('updated'); + }); +}); + +describe('TaskListStatus', () => { + it('Should not show filters for one status', () => { + const el = shallow(<TaskListStatusFilter + onClick={() => {}} + tasks={[ + ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(), + ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build()]} />); + expect( + el.contains(<div>All {2} tasks are <TaskListStatus status='PENDING' /></div>)).toBe(true); + }); + + it('Should show filters for multiple status', () => { + const el = shallow(<TaskListStatusFilter + onClick={() => {}} + tasks={[ + ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(), + ScheduledTaskBuilder.status(ScheduleStatus.RUNNING).build()]} />); + expect(el.find(TaskListStatus).length).toBe(2); + }); +}); + +describe('searchTask', () => { + it('Should match task by status', () => { + const el = ScheduledTaskBuilder.status(ScheduleStatus.PENDING).build(); + expect(searchTask(el, 'RUNNING')).toBe(false); + expect(searchTask(el, 'PENDING')).toBe(true); + expect(searchTask(el, 'pend')).toBe(true); + }); + + it('Should match task by instanceId', () => { + const el = ScheduledTaskBuilder.assignedTask( + AssignedTaskBuilder.instanceId(539).build() + ).build(); + expect(searchTask(el, '1')).toBe(false); + expect(searchTask(el, '5')).toBe(true); + expect(searchTask(el, '53')).toBe(true); + expect(searchTask(el, '539')).toBe(true); + }); + + it('Should match task by slaveHost', () => { + const el = ScheduledTaskBuilder.assignedTask( + AssignedTaskBuilder.slaveHost('aaa-zzz-123').build() + ).build(); + expect(searchTask(el, 'y')).toBe(false); + expect(searchTask(el, 'aAa')).toBe(true); + expect(searchTask(el, 'zZz')).toBe(true); + expect(searchTask(el, '123')).toBe(true); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/utils/Common.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/Common.js b/ui/src/main/js/utils/Common.js index 603a11b..d731394 100644 --- a/ui/src/main/js/utils/Common.js +++ b/ui/src/main/js/utils/Common.js @@ -37,6 +37,14 @@ export function sort(arr, prop, reverse = false) { }); } +export function pluralize(elements, singular, plural) { + if (elements.length === 1) { + return singular; + } + + return (isNully(plural)) ? `${singular}s` : plural; +} + export function range(start, end) { return [...Array(1 + end - start).keys()].map((i) => start + i); } http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/js/utils/__tests__/Common-test.js ---------------------------------------------------------------------- diff --git a/ui/src/main/js/utils/__tests__/Common-test.js b/ui/src/main/js/utils/__tests__/Common-test.js new file mode 100644 index 0000000..23f97ec --- /dev/null +++ b/ui/src/main/js/utils/__tests__/Common-test.js @@ -0,0 +1,19 @@ +import { pluralize } from '../Common'; + +describe('pluralize', () => { + it('Should treat empty lists as plural (e.g. zero tasks are...)', () => { + expect(pluralize([], 'task')).toBe('tasks'); + }); + + it('Should treat lists with multiple as plural', () => { + expect(pluralize([1, 2, 3], 'task')).toBe('tasks'); + }); + + it('Should treat single element lists as singular', () => { + expect(pluralize([1], 'task')).toBe('task'); + }); + + it('Should allow you to set your own plural form', () => { + expect(pluralize([1, 2], 'task', 'beetlejuice')).toBe('beetlejuice'); + }); +}); http://git-wip-us.apache.org/repos/asf/aurora/blob/5b91150f/ui/src/main/sass/components/_task-list.scss ---------------------------------------------------------------------- diff --git a/ui/src/main/sass/components/_task-list.scss b/ui/src/main/sass/components/_task-list.scss index 42b9cac..3c72219 100644 --- a/ui/src/main/sass/components/_task-list.scss +++ b/ui/src/main/sass/components/_task-list.scss @@ -88,4 +88,32 @@ overflow: hidden; } } +} + +.task-list-controls { + display: flex; + justify-content: space-between; + text-transform: lowercase; + margin-bottom: 10px; + + ul { + list-style-type: none; + padding: 0; + margin: 0; + + li { + float: left; + padding: 0px 5px; + } + + li.active { + font-weight: 600; + } + } + + .img-circle { + margin-right: 3px; + width: 7px; + height: 7px; + } } \ No newline at end of file
