You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by dm...@apache.org on 2017/10/23 19:48:30 UTC

aurora git commit: Add sorting and filtering controls for TaskList

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 <da...@dmclaughlin.com>
Authored: Mon Oct 23 12:48:20 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
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