Skip to content

Commit

Permalink
feat: experiment list filter [DET-2999, DET-3000] (#796)
Browse files Browse the repository at this point in the history
  • Loading branch information
Caleb Hoyoul Kang authored Jul 7, 2020
1 parent a1b494e commit e501ea0
Show file tree
Hide file tree
Showing 15 changed files with 174 additions and 39 deletions.
5 changes: 2 additions & 3 deletions webui/react/src/components/Label.module.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
.base {
font-size: 1.2rem;
}
.navSideBar {
cursor: pointer;
font-size: var(--theme-sizes-font-small);
}
.textOnly { cursor: auto; }
4 changes: 4 additions & 0 deletions webui/react/src/components/Label.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ export const NavMain = (): React.ReactNode => (
export const NavSideBar = (): React.ReactNode => (
<Label type={LabelTypes.NavSideBar}>NavSideBar Label</Label>
);

export const TextOnly = (): React.ReactNode => (
<Label type={LabelTypes.TextOnly}>TextOnly Label</Label>
);
1 change: 1 addition & 0 deletions webui/react/src/components/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import css from './Label.module.scss';
export enum LabelTypes {
NavMain = 'navMain',
NavSideBar = 'navSideBar',
TextOnly = 'textOnly',
}

interface Props {
Expand Down
6 changes: 2 additions & 4 deletions webui/react/src/components/SelectFilter.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
align-items: center;
display: flex;

.label {
font-size: var(--theme-sizes-font-medium);
font-weight: bold;
margin-right: var(--theme-sizes-layout-medium);
& > *:not(:first-child) {
margin-left: var(--theme-sizes-layout-medium);
}
}
3 changes: 2 additions & 1 deletion webui/react/src/components/SelectFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SelectValue } from 'antd/es/select';
import React, { PropsWithChildren, useCallback } from 'react';

import Icon from './Icon';
import Label from './Label';
import css from './SelectFilter.module.scss';

interface Props {
Expand Down Expand Up @@ -40,7 +41,7 @@ const SelectFilter: React.FC<PropsWithChildren<Props>> = (props: PropsWithChildr

return (
<div className={css.base}>
<div className={css.label}>{props.label}</div>
<Label>{props.label}</Label>
<Select
defaultValue={props.value}
dropdownMatchSelectWidth={false}
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const TaskCard: React.FC<RecentTask> = (props: RecentTask) => {
<div className={css.upper}>
<div className={css.icon}><Icon name={iconName} /></div>
<div className={css.info}>
<div className={css.name}>{props.title}</div>
<div className={css.name}>{props.name}</div>
<div className={css.age}>
<div className={css.event}>{props.lastEvent.name}</div>
<TimeAgo datetime={props.lastEvent.date} />
Expand Down
8 changes: 8 additions & 0 deletions webui/react/src/components/Toggle.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.base {
align-items: center;
display: flex;

& > *:not(:first-child) {
margin-left: var(--theme-sizes-layout-medium);
}
}
31 changes: 31 additions & 0 deletions webui/react/src/components/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Switch } from 'antd';
import React, { useCallback, useState } from 'react';

import Label from './Label';
import css from './Toggle.module.scss';

interface Props {
prefixLabel?: string;
checked?: boolean;
suffixLabel?: string;
onChange?: (checked: boolean) => void;
}

const Toggle: React.FC<Props> = ({ onChange, ...props }: Props) => {
const [ checked, setChecked ] = useState(props.checked || false);

const handleClick = useCallback(() => {
setChecked(!checked);
if (onChange) onChange(!checked);
}, [ checked, onChange ]);

return (
<div className={css.base} onClick={handleClick}>
{props.prefixLabel && <Label>{props.prefixLabel}</Label>}
<Switch checked={checked} />
{props.suffixLabel && <Label>{props.suffixLabel}</Label>}
</div>
);
};

export default Toggle;
5 changes: 5 additions & 0 deletions webui/react/src/pages/ExperimentList.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
justify-content: space-between;
margin-bottom: var(--theme-sizes-layout-medium);
}
.filters {
align-items: center;
display: flex;
}
.filters > *:not(:first-child) { margin-left: var(--theme-sizes-layout-jumbo); }
.search {
margin-right: var(--theme-sizes-layout-medium);
max-width: 30rem;
Expand Down
90 changes: 78 additions & 12 deletions webui/react/src/pages/ExperimentList.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,49 @@
import { Table } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Input, Table } from 'antd';
import { SelectValue } from 'antd/lib/select';
import React, { useCallback, useEffect, useMemo, useState } from 'react';

import Icon from 'components/Icon';
import { makeClickHandler } from 'components/Link';
import linkCss from 'components/Link.module.scss';
import Page from 'components/Page';
import StateSelectFilter from 'components/StateSelectFilter';
import Toggle from 'components/Toggle';
import UserSelectFilter from 'components/UserSelectFilter';
import Auth from 'contexts/Auth';
import Users from 'contexts/Users';
import usePolling from 'hooks/usePolling';
import { useRestApiSimple } from 'hooks/useRestApi';
import useStorage from 'hooks/useStorage';
import { getExperimentSummaries } from 'services/api';
import { ExperimentsParams } from 'services/types';
import { Experiment, ExperimentItem } from 'types';
import { processExperiments } from 'utils/task';
import { ALL_VALUE, Experiment, ExperimentFilters, ExperimentItem } from 'types';
import { filterExperiments, processExperiments } from 'utils/task';

import css from './ExperimentList.module.scss';
import { columns } from './ExperimentList.table';

const defaultFilters: ExperimentFilters = {
limit: 25,
showArchived: false,
states: [ ALL_VALUE ],
username: undefined,
};

const ExperimentList: React.FC = () => {
const auth = Auth.useStateContext();
const users = Users.useStateContext();
const [ experiments, setExperiments ] = useState<ExperimentItem[]>([]);
const [ experimentsResponse, requestExperiments ] =
useRestApiSimple<ExperimentsParams, Experiment[]>(getExperimentSummaries, {});
const storage = useStorage('experiment-list');
const initFilters = storage.getWithDefault('filters',
{ ...defaultFilters, username: (auth.user || {}).username });
const [ filters, setFilters ] = useState<ExperimentFilters>(initFilters);
const [ search, setSearch ] = useState('');

const filteredExperiments = useMemo(() => {
return filterExperiments(experiments, filters, users.data || [], search);
}, [ experiments, filters, search, users.data ]);

const fetchExperiments = useCallback((): void => {
requestExperiments({});
Expand All @@ -31,20 +56,61 @@ const ExperimentList: React.FC = () => {
setExperiments(experiments);
}, [ experimentsResponse, setExperiments, users ]);

const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value || '');
}, []);

const handleFilterChange = useCallback((filters: ExperimentFilters): void => {
storage.set('filters', filters);
setFilters(filters);
}, [ setFilters, storage ]);

const handleArchiveChange = useCallback((value: boolean): void => {
handleFilterChange({ ...filters, showArchived: value });
}, [ filters, handleFilterChange ]);

const handleStateChange = useCallback((value: SelectValue): void => {
if (typeof value !== 'string') return;
handleFilterChange({ ...filters, states: [ value ] });
}, [ filters, handleFilterChange ]);

const handleUserChange = useCallback((value: SelectValue) => {
const username = value === ALL_VALUE ? undefined : value as string;
handleFilterChange({ ...filters, username });
}, [ filters, handleFilterChange ]);

const handleTableRow = useCallback((record: ExperimentItem) => ({
onClick: makeClickHandler(record.url as string),
}), []);

return (
<Page title="Experiments">
<Table
columns={columns}
dataSource={experiments}
loading={experiments === undefined}
rowClassName={(): string => linkCss.base}
rowKey="id"
size="small"
onRow={handleTableRow} />
<div className={css.base}>
<div className={css.header}>
<Input
allowClear
className={css.search}
placeholder="name"
prefix={<Icon name="search" size="small" />}
onChange={handleSearchChange} />
<div className={css.filters}>
<Toggle prefixLabel="Show Archived" onChange={handleArchiveChange} />
<StateSelectFilter
showCommandStates={false}
value={filters.states}
onChange={handleStateChange} />
<UserSelectFilter value={filters.username} onChange={handleUserChange} />
</div>
</div>
<Table
columns={columns}
dataSource={filteredExperiments}
loading={!experimentsResponse.hasLoaded}
rowClassName={(): string => linkCss.base}
rowKey="id"
size="small"
onRow={handleTableRow} />
</div>
</Page>
);
};
Expand Down
4 changes: 2 additions & 2 deletions webui/react/src/pages/TaskList.table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const columns: ColumnsType<CommandTask> = [
title: 'Type',
},
{
dataIndex: 'title',
sorter: (a, b): number => alphanumericSorter(a.title, b.title),
dataIndex: 'name',
sorter: (a, b): number => alphanumericSorter(a.name, b.name),
title: 'Name',
},
{
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/pages/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const TaskList: React.FC = () => {
const notebooks = Notebooks.useStateContext();
const shells = Shells.useStateContext();
const tensorboards = Tensorboards.useStateContext();
const storage = useStorage('tasklist');
const storage = useStorage('task-list');
const initFilters = storage.getWithDefault('filters',
{ ...defaultFilters, username: (auth.user || {}).username });
const [ filters, setFilters ] = useState<TaskFilters<CommandType>>(initFilters);
Expand Down
9 changes: 8 additions & 1 deletion webui/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export interface ExperimentDetails extends Experiment {
}

export interface Task {
title: string;
name: string;
id: string;
ownerId: number;
url?: string;
Expand Down Expand Up @@ -236,6 +236,13 @@ export type PropsWithClassName<T> = T & {className?: string};

export type TaskType = CommandType | 'Experiment';

export interface ExperimentFilters {
showArchived: boolean;
limit: number;
states: string[];
username?: string;
}

export interface TaskFilters<T extends TaskType = TaskType> {
limit: number;
states: string[];
Expand Down
35 changes: 25 additions & 10 deletions webui/react/src/utils/task.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ALL_VALUE, AnyTask, CommandState, CommandTask, CommandType, Experiment, ExperimentItem,
ExperimentTask, RecentCommandTask, RecentEvent, RecentExperimentTask, RecentTask, RunState, Task,
TaskFilters, TaskType, terminalCommandStates, User,
ALL_VALUE, AnyTask, CommandState, CommandTask, CommandType, Experiment, ExperimentFilters,
ExperimentItem, ExperimentTask, RecentCommandTask, RecentEvent, RecentExperimentTask,
RecentTask, RunState, Task, TaskFilters, TaskType, terminalCommandStates, User,
} from 'types';

import { isExperiment } from './types';
Expand Down Expand Up @@ -30,9 +30,9 @@ function generateTask(idx: number): Task & RecentEvent {
date: startTime,
name: 'opened',
},
name: `${idx}`,
ownerId: user.id,
startTime,
title: `${idx}`,
url: '#',
};
}
Expand Down Expand Up @@ -71,9 +71,9 @@ export const generateExperiments = (count = 10): ExperimentItem[] => {
const user = sampleUsers[Math.floor(Math.random() * sampleUsers.length)];
return {
...experimentTask,
config: { description: experimentTask.title },
config: { description: experimentTask.name },
id: idx,
name: experimentTask.title,
name: experimentTask.name,
username: user.username,
} as ExperimentItem;
});
Expand All @@ -100,12 +100,12 @@ export const canBeOpened = (task: AnyTask): boolean => {
return !!task.url;
};

const matchesSearch = <T extends AnyTask>(task: T, search = ''): boolean => {
const matchesSearch = <T extends AnyTask | ExperimentItem>(task: T, search = ''): boolean => {
if (!search) return true;
return task.id.indexOf(search) !== -1 || task.title.indexOf(search) !== -1;
return task.id.toString().indexOf(search) !== -1 || task.name.indexOf(search) !== -1;
};

const matchesState = <T extends AnyTask>(task: T, states: string[]): boolean => {
const matchesState = <T extends AnyTask | ExperimentItem>(task: T, states: string[]): boolean => {
if (states[0] === ALL_VALUE) return true;

const targetStateRun = states[0] as RunState;
Expand All @@ -114,12 +114,27 @@ const matchesState = <T extends AnyTask>(task: T, states: string[]): boolean =>
return [ targetStateRun, targetStateCmd ].includes(task.state);
};

const matchesUser = <T extends AnyTask>(task: T, users: User[], username?: string): boolean => {
const matchesUser = <T extends AnyTask | ExperimentItem>(
task: T, users: User[], username?: string,
): boolean => {
if (!username) return true;
const selectedUser = users.find(u => u.username === username);
return !!selectedUser && (task.ownerId === selectedUser.id);
};

export const filterExperiments = (
experiments: ExperimentItem[], filters: ExperimentFilters, users: User[] = [], search = '',
): ExperimentItem[] => {
return experiments
.filter(experiment => {
return (filters.showArchived || !experiment.archived) &&
matchesUser<ExperimentItem>(experiment, users, filters.username) &&
matchesState<ExperimentItem>(experiment, filters.states) &&
matchesSearch<ExperimentItem>(experiment, search);
})
.slice(0, filters.limit);
};

export const filterTasks = <T extends TaskType = TaskType, A extends AnyTask = AnyTask>(
tasks: A[], filters: TaskFilters<T>, users: User[], search = '',
): A[] => {
Expand Down
8 changes: 4 additions & 4 deletions webui/react/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ const waitPageUrl = (command: Command): string | undefined => {
};

export const commandToTask = (command: Command): RecentCommandTask => {
// We expect the title to be in the form of 'Type (pet-name-generated)'.
const title = command.config.description.replace(/.*\((.*)\).*/, '$1');
// We expect the name to be in the form of 'Type (pet-name-generated)'.
const name = command.config.description.replace(/.*\((.*)\).*/, '$1');
const task: RecentTask = {
id: command.id,
lastEvent: {
date: command.registeredTime,
name: 'requested',
},
misc: command.misc,
name,
ownerId: command.owner.id,
startTime: command.registeredTime,
state: command.state as CommandState,
title,
type: command.kind,
url: waitPageUrl(command),
username: command.owner.username,
Expand All @@ -49,11 +49,11 @@ export const experimentToTask = (experiment: Experiment): RecentExperimentTask =
archived: experiment.archived,
id: `${experiment.id}`,
lastEvent,
name: experiment.config.description,
ownerId: experiment.ownerId,
progress: experiment.progress,
startTime: experiment.startTime,
state: experiment.state,
title: experiment.config.description,
url: `/ui/experiments/${experiment.id}`,
};
return task;
Expand Down

0 comments on commit e501ea0

Please sign in to comment.