From: Pawel Kowalczyk Date: Mon, 25 Jun 2018 10:05:09 +0000 (+0200) Subject: merge-conflicts X-Git-Tag: 1.2.0~68^2~2 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/c90e813adcec89899d9db95843295a84fb058c3e?hp=ae7d952a97542c2cfc12f6f41ab0de93af278919 merge-conflicts Feature ##13598 Arvados-DCO-1.1-Signed-off-by: Pawel Kowalczyk --- diff --git a/Makefile b/Makefile index a543d464..30ab7bc4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ # # SPDX-License-Identifier: Apache-2.0 +# Use bash, and run all lines in each recipe as one shell command +SHELL := /bin/bash +.ONESHELL: + APP_NAME?=arvados-workbench2 # GIT_TAG is the last tagged stable release (i.e. 1.2.0) @@ -34,7 +38,7 @@ DEST_DIR=/var/www/arvados-workbench2/workbench2/ DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb # redHat package file -RPM_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION).x86_64.rpm +RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm export WORKSPACE?=$(shell pwd) @@ -94,5 +98,16 @@ $(RPM_FILE): build --description="$(DESCRIPTION)" \ $(WORKSPACE)/build/=$(DEST_DIR) +copy: $(DEB_FILE) $(RPM_FILE) + for target in $(TARGETS); do \ + if [[ $$target =~ ^centos ]]; then + cp -p $(RPM_FILE) packages/$$target ; \ + else + cp -p $(DEB_FILE) packages/$$target ; \ + fi + done + rm -f $(RPM_FILE) + rm -f $(DEB_FILE) + # use FPM to create DEB and RPM -packages: $(DEB_FILE) $(RPM_FILE) +packages: copy diff --git a/__mocks__/popper.js.js b/__mocks__/popper.js.js new file mode 100644 index 00000000..07c78568 --- /dev/null +++ b/__mocks__/popper.js.js @@ -0,0 +1,30 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export default class Popper { + static placements = [ + 'auto', + 'auto-end', + 'auto-start', + 'bottom', + 'bottom-end', + 'bottom-start', + 'left', + 'left-end', + 'left-start', + 'right', + 'right-end', + 'right-start', + 'top', + 'top-end', + 'top-start' + ]; + + constructor() { + return { + destroy: jest.fn(), + scheduleUpdate: jest.fn() + }; + } +} \ No newline at end of file diff --git a/package.json b/package.json index fda2ead6..3db24045 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@material-ui/icons": "1.1.0", "@types/lodash": "4.14.109", "axios": "0.18.0", + "classnames": "^2.2.6", "lodash": "4.17.10", "react": "16.4.1", "react-dom": "16.4.1", @@ -27,11 +28,12 @@ "lint": "tslint src/** -t verbose" }, "devDependencies": { + "@types/classnames": "^2.2.4", "@types/enzyme": "3.1.10", "@types/enzyme-adapter-react-16": "1.0.2", "@types/jest": "23.1.0", "@types/node": "10.3.3", - "@types/react": "16.3.18", + "@types/react": "16.3", "@types/react-dom": "16.0.6", "@types/react-redux": "6.0.2", "@types/react-router": "4.0.26", diff --git a/src/common/formatters.ts b/src/common/formatters.ts new file mode 100644 index 00000000..1d9a5201 --- /dev/null +++ b/src/common/formatters.ts @@ -0,0 +1,42 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export const formatDate = (isoDate: string) => { + const date = new Date(isoDate); + return date.toLocaleString(); +}; + +export const formatFileSize = (size?: number) => { + if (typeof size === "number") { + for (const { base, unit } of fileSizes) { + if (size >= base) { + return `${(size / base).toFixed()} ${unit}`; + } + } + } + return ""; +}; + +const fileSizes = [ + { + base: 1000000000000, + unit: "TB" + }, + { + base: 1000000000, + unit: "GB" + }, + { + base: 1000000, + unit: "MB" + }, + { + base: 1000, + unit: "KB" + }, + { + base: 1, + unit: "B" + } +]; \ No newline at end of file diff --git a/src/components/breadcrumbs/breadcrumbs.test.tsx b/src/components/breadcrumbs/breadcrumbs.test.tsx index 77beb494..b525554a 100644 --- a/src/components/breadcrumbs/breadcrumbs.test.tsx +++ b/src/components/breadcrumbs/breadcrumbs.test.tsx @@ -22,29 +22,29 @@ describe("", () => { it("renders one item", () => { const items = [ - {label: 'breadcrumb 1'} + { label: 'breadcrumb 1' } ]; - const breadcrumbs = mount(); + const breadcrumbs = mount(); expect(breadcrumbs.find(Button)).toHaveLength(1); expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0); }); - + it("renders multiple items", () => { const items = [ - {label: 'breadcrumb 1'}, - {label: 'breadcrumb 2'} + { label: 'breadcrumb 1' }, + { label: 'breadcrumb 2' } ]; - const breadcrumbs = mount(); + const breadcrumbs = mount(); expect(breadcrumbs.find(Button)).toHaveLength(2); expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1); }); - + it("calls onClick with clicked item", () => { const items = [ - {label: 'breadcrumb 1'}, - {label: 'breadcrumb 2'} + { label: 'breadcrumb 1' }, + { label: 'breadcrumb 2' } ]; - const breadcrumbs = mount(); + const breadcrumbs = mount(); breadcrumbs.find(Button).at(1).simulate('click'); expect(onClick).toBeCalledWith(items[1]); }); diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 25f30a1b..41f71981 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; -import { Button, Grid, StyleRulesCallback, WithStyles } from '@material-ui/core'; +import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } from '@material-ui/core'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import { withStyles } from '@material-ui/core'; @@ -17,19 +17,27 @@ interface BreadcrumbsProps { } const Breadcrumbs: React.SFC> = ({ classes, onClick, items }) => { - return + return { items.map((item, index) => { const isLastItem = index === items.length - 1; return ( - + + + { !isLastItem && } @@ -40,7 +48,7 @@ const Breadcrumbs: React.SFC> = ({ class ; }; -type CssRules = "item" | "currentItem"; +type CssRules = "item" | "currentItem" | "label"; const styles: StyleRulesCallback = theme => { const { unit } = theme.spacing; @@ -50,6 +58,9 @@ const styles: StyleRulesCallback = theme => { }, currentItem: { opacity: 1 + }, + label: { + textTransform: "none" } }; }; diff --git a/src/components/column-selector/column-selector.test.tsx b/src/components/column-selector/column-selector.test.tsx new file mode 100644 index 00000000..b6c544fb --- /dev/null +++ b/src/components/column-selector/column-selector.test.tsx @@ -0,0 +1,79 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure } from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; +import ColumnSelector, { ColumnSelectorProps, ColumnSelectorTrigger } from "./column-selector"; +import { DataColumn } from "../data-table/data-column"; +import { ListItem, Checkbox } from "@material-ui/core"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + it("shows only configurable columns", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + }, + { + name: "Column 2", + render: () => , + selected: true, + configurable: true, + }, + { + name: "Column 3", + render: () => , + selected: true, + configurable: false + } + ]; + const columnsConfigurator = mount(); + columnsConfigurator.find(ColumnSelectorTrigger).simulate("click"); + expect(columnsConfigurator.find(ListItem)).toHaveLength(2); + }); + + it("renders checked checkboxes next to selected columns", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + }, + { + name: "Column 2", + render: () => , + selected: false + }, + { + name: "Column 3", + render: () => , + selected: true + } + ]; + const columnsConfigurator = mount(); + columnsConfigurator.find(ColumnSelectorTrigger).simulate("click"); + expect(columnsConfigurator.find(Checkbox).at(0).prop("checked")).toBe(true); + expect(columnsConfigurator.find(Checkbox).at(1).prop("checked")).toBe(false); + expect(columnsConfigurator.find(Checkbox).at(2).prop("checked")).toBe(true); + }); + + it("calls onColumnToggle with clicked column", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + } + ]; + const onColumnToggle = jest.fn(); + const columnsConfigurator = mount(); + columnsConfigurator.find(ColumnSelectorTrigger).simulate("click"); + columnsConfigurator.find(ListItem).simulate("click"); + expect(onColumnToggle).toHaveBeenCalledWith(columns[0]); + }); +}); diff --git a/src/components/column-selector/column-selector.tsx b/src/components/column-selector/column-selector.tsx new file mode 100644 index 00000000..e2286b00 --- /dev/null +++ b/src/components/column-selector/column-selector.tsx @@ -0,0 +1,56 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { WithStyles, StyleRulesCallback, Theme, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem } from '@material-ui/core'; +import MenuIcon from "@material-ui/icons/Menu"; +import { DataColumn, isColumnConfigurable } from '../data-table/data-column'; +import Popover from "../popover/popover"; +import { IconButtonProps } from '@material-ui/core/IconButton'; + +export interface ColumnSelectorProps { + columns: Array>; + onColumnToggle: (column: DataColumn) => void; +} + +const ColumnSelector: React.SFC> = ({ columns, onColumnToggle, classes }) => + + + + {columns + .filter(isColumnConfigurable) + .map((column, index) => ( + onColumnToggle(column)}> + + + {column.name} + + + ))} + + + ; + +export const ColumnSelectorTrigger: React.SFC = (props) => + + + ; + +type CssRules = "checkbox"; + +const styles: StyleRulesCallback = (theme: Theme) => ({ + checkbox: { + width: 24, + height: 24 + } +}); + +export default withStyles(styles)(ColumnSelector); diff --git a/src/components/context-menu/context-menu.test.tsx b/src/components/context-menu/context-menu.test.tsx new file mode 100644 index 00000000..e4e2397d --- /dev/null +++ b/src/components/context-menu/context-menu.test.tsx @@ -0,0 +1,35 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure, shallow } from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; +import ContextMenu from "./context-menu"; +import { ListItem } from "@material-ui/core"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + const actions = [[{ + icon: "", + name: "Action 1.1" + }, { + icon: "", + name: "Action 1.2" + },], [{ + icon: "", + name: "Action 2.1" + }]]; + + it("calls onActionClick with clicked action", () => { + const onActionClick = jest.fn(); + const contextMenu = mount(); + contextMenu.find(ListItem).at(2).simulate("click"); + expect(onActionClick).toHaveBeenCalledWith(actions[1][0]); + }); +}); \ No newline at end of file diff --git a/src/components/context-menu/context-menu.tsx b/src/components/context-menu/context-menu.tsx new file mode 100644 index 00000000..7751be49 --- /dev/null +++ b/src/components/context-menu/context-menu.tsx @@ -0,0 +1,51 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 +import * as React from "react"; +import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core"; +import { DefaultTransformOrigin } from "../popover/helpers"; + +export interface ContextMenuAction { + name: string; + icon: string; +} + +export type ContextMenuActionGroup = ContextMenuAction[]; + +export interface ContextMenuProps { + anchorEl?: HTMLElement; + actions: ContextMenuActionGroup[]; + onActionClick: (action: ContextMenuAction) => void; + onClose: () => void; +} + +export default class ContextMenu extends React.PureComponent> { + render() { + const { anchorEl, actions, onClose, onActionClick } = this.props; + return + + {actions.map((group, groupIndex) => + + {group.map((action, actionIndex) => + onActionClick(action)}> + + + + + {action.name} + + )} + {groupIndex < actions.length - 1 && } + )} + + ; + } +} diff --git a/src/components/data-explorer/data-explorer.test.tsx b/src/components/data-explorer/data-explorer.test.tsx new file mode 100644 index 00000000..eff49923 --- /dev/null +++ b/src/components/data-explorer/data-explorer.test.tsx @@ -0,0 +1,130 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { configure, mount } from "enzyme"; +import * as Adapter from 'enzyme-adapter-react-16'; + +import DataExplorer from "./data-explorer"; +import ContextMenu from "../context-menu/context-menu"; +import ColumnSelector from "../column-selector/column-selector"; +import DataTable from "../data-table/data-table"; +import SearchInput from "../search-input/search-input"; +import { TablePagination } from "@material-ui/core"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + + it("communicates with ", () => { + const onContextAction = jest.fn(); + const dataExplorer = mount(); + expect(dataExplorer.find(ContextMenu).prop("actions")).toEqual([]); + dataExplorer.find(DataTable).prop("onRowContextMenu")({ + preventDefault: jest.fn() + }, "Item 1"); + dataExplorer.find(ContextMenu).prop("onActionClick")({ name: "Action 1", icon: "" }); + expect(onContextAction).toHaveBeenCalledWith({ name: "Action 1", icon: "" }, "Item 1"); + }); + + it("communicates with ", () => { + const onSearch = jest.fn(); + const dataExplorer = mount(); + expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value"); + dataExplorer.find(SearchInput).prop("onSearch")("new value"); + expect(onSearch).toHaveBeenCalledWith("new value"); + }); + + it("communicates with ", () => { + const onColumnToggle = jest.fn(); + const columns = [{ name: "Column 1", render: jest.fn(), selected: true }]; + const dataExplorer = mount(); + expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns); + dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns"); + expect(onColumnToggle).toHaveBeenCalledWith("columns"); + }); + + it("communicates with ", () => { + const onFiltersChange = jest.fn(); + const onSortToggle = jest.fn(); + const onRowClick = jest.fn(); + const columns = [{ name: "Column 1", render: jest.fn(), selected: true }]; + const items = ["Item 1"]; + const dataExplorer = mount(); + expect(dataExplorer.find(DataTable).prop("columns")).toBe(columns); + expect(dataExplorer.find(DataTable).prop("items")).toBe(items); + dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick"); + dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange"); + dataExplorer.find(DataTable).prop("onSortToggle")("sortToggle"); + expect(onFiltersChange).toHaveBeenCalledWith("filtersChange"); + expect(onSortToggle).toHaveBeenCalledWith("sortToggle"); + expect(onRowClick).toHaveBeenCalledWith("rowClick"); + }); + + it("does not render , and if there is no items", () => { + const dataExplorer = mount(); + expect(dataExplorer.find(SearchInput)).toHaveLength(0); + expect(dataExplorer.find(ColumnSelector)).toHaveLength(0); + expect(dataExplorer.find(TablePagination)).toHaveLength(0); + }); + + it("communicates with ", () => { + const onChangePage = jest.fn(); + const onChangeRowsPerPage = jest.fn(); + const dataExplorer = mount(); + expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10); + expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50); + dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6); + dataExplorer.find(TablePagination).prop("onChangeRowsPerPage")({ target: { value: 10 } }); + expect(onChangePage).toHaveBeenCalledWith(6); + expect(onChangeRowsPerPage).toHaveBeenCalledWith(10); + }); +}); + +const mockDataExplorerProps = () => ({ + columns: [], + items: [], + contextActions: [], + searchValue: "", + page: 0, + rowsPerPage: 0, + onSearch: jest.fn(), + onFiltersChange: jest.fn(), + onSortToggle: jest.fn(), + onRowClick: jest.fn(), + onColumnToggle: jest.fn(), + onContextAction: jest.fn(), + onChangePage: jest.fn(), + onChangeRowsPerPage: jest.fn() +}); \ No newline at end of file diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx new file mode 100644 index 00000000..6a3103b1 --- /dev/null +++ b/src/components/data-explorer/data-explorer.tsx @@ -0,0 +1,149 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, Theme, WithStyles, TablePagination, Table, IconButton } from '@material-ui/core'; +import MoreVertIcon from "@material-ui/icons/MoreVert"; +import ContextMenu, { ContextMenuActionGroup, ContextMenuAction } from "../../components/context-menu/context-menu"; +import ColumnSelector from "../../components/column-selector/column-selector"; +import DataTable from "../../components/data-table/data-table"; +import { mockAnchorFromMouseEvent } from "../../components/popover/helpers"; +import { DataColumn, toggleSortDirection } from "../../components/data-table/data-column"; +import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters'; +import SearchInput from '../search-input/search-input'; + +interface DataExplorerProps { + items: T[]; + columns: Array>; + contextActions: ContextMenuActionGroup[]; + searchValue: string; + rowsPerPage: number; + page: number; + onSearch: (value: string) => void; + onRowClick: (item: T) => void; + onColumnToggle: (column: DataColumn) => void; + onContextAction: (action: ContextMenuAction, item: T) => void; + onSortToggle: (column: DataColumn) => void; + onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn) => void; + onChangePage: (page: number) => void; + onChangeRowsPerPage: (rowsPerPage: number) => void; +} + +interface DataExplorerState { + contextMenu: { + anchorEl?: HTMLElement; + item?: T; + }; +} + +class DataExplorer extends React.Component & WithStyles, DataExplorerState> { + state: DataExplorerState = { + contextMenu: {} + }; + + render() { + return + + + {this.props.items.length > 0 && + +
+ +
+ +
} + +
+ this.props.onRowClick(item)} + onRowContextMenu={this.openContextMenu} + onFiltersChange={this.props.onFiltersChange} + onSortToggle={this.props.onSortToggle} /> + + {this.props.items.length > 0 && + + + } + +
; + } + + openContextMenu = (event: React.MouseEvent, item: T) => { + event.preventDefault(); + this.setState({ + contextMenu: { + anchorEl: mockAnchorFromMouseEvent(event), + item + } + }); + } + + closeContextMenu = () => { + this.setState({ contextMenu: {} }); + } + + callAction = (action: ContextMenuAction) => { + const { item } = this.state.contextMenu; + this.closeContextMenu(); + if (item) { + this.props.onContextAction(action, item); + } + } + + changePage = (event: React.MouseEvent | null, page: number) => { + this.props.onChangePage(page); + } + + changeRowsPerPage: React.ChangeEventHandler = (event) => { + this.props.onChangeRowsPerPage(parseInt(event.target.value, 10)); + } + + renderContextMenuTrigger = (item: T) => + + this.openContextMenu(event, item)}> + + + + + contextMenuColumn = { + name: "Actions", + selected: true, + key: "context-actions", + renderHeader: () => null, + render: this.renderContextMenuTrigger + }; + +} + +type CssRules = "searchBox" | "toolbar"; + +const styles: StyleRulesCallback = (theme: Theme) => ({ + searchBox: { + paddingBottom: theme.spacing.unit * 2 + }, + toolbar: { + paddingTop: theme.spacing.unit * 2 + } +}); + +export default withStyles(styles)(DataExplorer); diff --git a/src/components/data-table-filters/data-table-filters.test.tsx b/src/components/data-table-filters/data-table-filters.test.tsx new file mode 100644 index 00000000..b2daebef --- /dev/null +++ b/src/components/data-table-filters/data-table-filters.test.tsx @@ -0,0 +1,68 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure } from "enzyme"; +import DataTableFilter, { DataTableFilterItem } from "./data-table-filters"; +import * as Adapter from 'enzyme-adapter-react-16'; +import { Checkbox, ButtonBase, ListItem, Button, ListItemText } from "@material-ui/core"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + it("renders filters according to their state", () => { + const filters = [{ + name: "Filter 1", + selected: true + }, { + name: "Filter 2", + selected: false + }]; + const dataTableFilter = mount(); + dataTableFilter.find(ButtonBase).simulate("click"); + expect(dataTableFilter.find(Checkbox).at(0).prop("checked")).toBeTruthy(); + expect(dataTableFilter.find(Checkbox).at(1).prop("checked")).toBeFalsy(); + }); + + it("updates filters after filters prop change", () => { + const filters = [{ + name: "Filter 1", + selected: true + }]; + const updatedFilters = [, { + name: "Filter 2", + selected: true + }]; + const dataTableFilter = mount(); + dataTableFilter.find(ButtonBase).simulate("click"); + expect(dataTableFilter.find(Checkbox).prop("checked")).toBeTruthy(); + dataTableFilter.find(ListItem).simulate("click"); + expect(dataTableFilter.find(Checkbox).prop("checked")).toBeFalsy(); + dataTableFilter.setProps({filters: updatedFilters}); + expect(dataTableFilter.find(Checkbox).prop("checked")).toBeTruthy(); + expect(dataTableFilter.find(ListItemText).text()).toBe("Filter 2"); + }); + + it("calls onChange with modified list of filters", () => { + const filters = [{ + name: "Filter 1", + selected: true + }, { + name: "Filter 2", + selected: false + }]; + const onChange = jest.fn(); + const dataTableFilter = mount(); + dataTableFilter.find(ButtonBase).simulate("click"); + dataTableFilter.find(ListItem).at(1).simulate("click"); + dataTableFilter.find(Button).at(0).simulate("click"); + expect(onChange).toHaveBeenCalledWith([{ + name: "Filter 1", + selected: true + }, { + name: "Filter 2", + selected: true + }]); + }); +}); \ No newline at end of file diff --git a/src/components/data-table-filters/data-table-filters.tsx b/src/components/data-table-filters/data-table-filters.tsx new file mode 100644 index 00000000..bede5aea --- /dev/null +++ b/src/components/data-table-filters/data-table-filters.tsx @@ -0,0 +1,188 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { + WithStyles, + withStyles, + ButtonBase, + StyleRulesCallback, + Theme, + Popover, + List, + ListItem, + Checkbox, + ListItemText, + Button, + Card, + CardActions, + Typography, + CardContent +} from "@material-ui/core"; +import * as classnames from "classnames"; +import { DefaultTransformOrigin } from "../popover/helpers"; + +export interface DataTableFilterItem { + name: string; + selected: boolean; +} + +export interface DataTableFilterProps { + name: string; + filters: DataTableFilterItem[]; + onChange?: (filters: DataTableFilterItem[]) => void; +} + +interface DataTableFilterState { + anchorEl?: HTMLElement; + filters: DataTableFilterItem[]; + prevFilters: DataTableFilterItem[]; +} + +class DataTableFilter extends React.Component, DataTableFilterState> { + state: DataTableFilterState = { + anchorEl: undefined, + filters: [], + prevFilters: [] + }; + icon = React.createRef(); + + render() { + const { name, classes, children } = this.props; + const isActive = this.state.filters.some(f => f.selected); + return <> + + {children} + + + + + + + {name} + + + + {this.state.filters.map((filter, index) => + + + + {filter.name} + + + )} + + + + + + + + ; + } + + static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState { + return props.filters !== state.prevFilters + ? { ...state, filters: props.filters, prevFilters: props.filters } + : state; + } + + open = () => { + this.setState({ anchorEl: this.icon.current || undefined }); + } + + submit = () => { + const { onChange } = this.props; + if (onChange) { + onChange(this.state.filters); + } + this.setState({ anchorEl: undefined }); + } + + cancel = () => { + this.setState(prev => ({ + ...prev, + filters: prev.prevFilters, + anchorEl: undefined + })); + } + + toggleFilter = (toggledFilter: DataTableFilterItem) => () => { + this.setState(prev => ({ + ...prev, + filters: prev.filters.map(filter => + filter === toggledFilter + ? { ...filter, selected: !filter.selected } + : filter) + })); + } +} + + +export type CssRules = "root" | "icon" | "active" | "checkbox"; + +const styles: StyleRulesCallback = (theme: Theme) => ({ + root: { + cursor: "pointer", + display: "inline-flex", + justifyContent: "flex-start", + flexDirection: "inherit", + alignItems: "center", + "&:hover": { + color: theme.palette.text.primary, + }, + "&:focus": { + color: theme.palette.text.primary, + }, + }, + active: { + color: theme.palette.text.primary, + '& $icon': { + opacity: 1, + }, + }, + icon: { + marginRight: 4, + marginLeft: 4, + opacity: 0.7, + userSelect: "none", + width: 16 + }, + checkbox: { + width: 24, + height: 24 + } +}); + +export default withStyles(styles)(DataTableFilter); diff --git a/src/components/data-table/data-column.ts b/src/components/data-table/data-column.ts new file mode 100644 index 00000000..1ef7d98f --- /dev/null +++ b/src/components/data-table/data-column.ts @@ -0,0 +1,34 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { DataTableFilterItem } from "../data-table-filters/data-table-filters"; + +export interface DataColumn { + name: string; + selected: boolean; + configurable?: boolean; + key?: React.Key; + sortDirection?: SortDirection; + filters?: DataTableFilterItem[]; + render: (item: T) => React.ReactElement; + renderHeader?: () => React.ReactElement | null; +} + +export type SortDirection = "asc" | "desc" | "none"; + +export const isColumnConfigurable = (column: DataColumn) => { + return column.configurable === undefined || column.configurable; +}; + +export const toggleSortDirection = (column: DataColumn): DataColumn => { + return column.sortDirection + ? column.sortDirection === "asc" + ? { ...column, sortDirection: "desc" } + : { ...column, sortDirection: "asc" } + : column; +}; + +export const resetSortDirection = (column: DataColumn): DataColumn => { + return column.sortDirection ? { ...column, sortDirection: "none" } : column; +}; diff --git a/src/components/data-table/data-table.test.tsx b/src/components/data-table/data-table.test.tsx new file mode 100644 index 00000000..439e6c27 --- /dev/null +++ b/src/components/data-table/data-table.test.tsx @@ -0,0 +1,185 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure } from "enzyme"; +import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core"; +import * as Adapter from "enzyme-adapter-react-16"; +import DataTable from "./data-table"; +import { DataColumn } from "./data-column"; +import DataTableFilters from "../data-table-filters/data-table-filters"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + it("shows only selected columns", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + }, + { + name: "Column 2", + render: () => , + selected: true + }, + { + name: "Column 3", + render: () => , + selected: false + } + ]; + const dataTable = mount(); + expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2); + }); + + it("renders column name", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + } + ]; + const dataTable = mount(); + expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1"); + }); + + it("uses renderHeader instead of name prop", () => { + const columns: Array> = [ + { + name: "Column 1", + renderHeader: () => Column Header, + render: () => , + selected: true + } + ]; + const dataTable = mount(); + expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header"); + }); + + it("passes column key prop to corresponding cells", () => { + const columns: Array> = [ + { + name: "Column 1", + key: "column-1-key", + render: () => , + selected: true + } + ]; + const dataTable = mount(); + expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key"); + expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key"); + }); + + it("shows information that items array is empty", () => { + const columns: Array> = [ + { + name: "Column 1", + render: () => , + selected: true + } + ]; + const dataTable = mount(); + expect(dataTable.find(Typography).text()).toBe("No items"); + }); + + it("renders items", () => { + const columns: Array> = [ + { + name: "Column 1", + render: (item) => {item}, + selected: true + }, + { + name: "Column 2", + render: (item) => , + selected: true + } + ]; + const dataTable = mount(); + expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1"); + expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1"); + }); + + it("passes sorting props to ", () => { + const columns: Array> = [{ + name: "Column 1", + sortDirection: "asc", + selected: true, + render: (item) => {item} + }]; + const onSortToggle = jest.fn(); + const dataTable = mount(); + expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy(); + dataTable.find(TableSortLabel).at(0).simulate("click"); + expect(onSortToggle).toHaveBeenCalledWith(columns[0]); + }); + + it("passes filter props to ", () => { + const columns: Array> = [{ + name: "Column 1", + sortDirection: "asc", + selected: true, + filters: [{name: "Filter 1", selected: true}], + render: (item) => {item} + }]; + const onFiltersChange = jest.fn(); + const dataTable = mount(); + expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters); + dataTable.find(DataTableFilters).prop("onChange")([]); + expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]); + }); + + +}); \ No newline at end of file diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx new file mode 100644 index 00000000..e86113ef --- /dev/null +++ b/src/components/data-table/data-table.tsx @@ -0,0 +1,110 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, Typography } from '@material-ui/core'; +import { DataColumn, SortDirection } from './data-column'; +import DataTableFilters, { DataTableFilterItem } from "../data-table-filters/data-table-filters"; + +export type DataColumns = Array>; + +export interface DataTableProps { + items: T[]; + columns: DataColumns; + onRowClick: (event: React.MouseEvent, item: T) => void; + onRowContextMenu: (event: React.MouseEvent, item: T) => void; + onSortToggle: (column: DataColumn) => void; + onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn) => void; +} + +class DataTable extends React.Component & WithStyles> { + render() { + const { items, classes } = this.props; + return
+ {items.length > 0 ? + + + + {this.mapVisibleColumns(this.renderHeadCell)} + + + + {items.map(this.renderBodyRow)} + +
: + No items + } +
; + } + + renderHeadCell = (column: DataColumn, index: number) => { + const { name, key, renderHeader, filters, sortDirection } = column; + const { onSortToggle, onFiltersChange } = this.props; + return + {renderHeader ? + renderHeader() : + filters + ? + onFiltersChange && + onFiltersChange(filters, column)} + filters={filters}> + {name} + + : sortDirection + ? + onSortToggle && + onSortToggle(column)}> + {name} + + : + {name} + } + ; + } + + renderBodyRow = (item: T, index: number) => { + const { columns, onRowClick, onRowContextMenu } = this.props; + return onRowClick && onRowClick(event, item)} + onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}> + {this.mapVisibleColumns((column, index) => ( + + {column.render(item)} + + ))} + ; + } + + mapVisibleColumns = (fn: (column: DataColumn, index: number) => React.ReactElement) => { + return this.props.columns.filter(column => column.selected).map(fn); + } + +} + +type CssRules = "tableBody" | "tableContainer" | "noItemsInfo"; + +const styles: StyleRulesCallback = (theme: Theme) => ({ + tableContainer: { + overflowX: 'auto' + }, + tableBody: { + background: theme.palette.background.paper + }, + noItemsInfo: { + textAlign: "center", + padding: theme.spacing.unit + } +}); + +export default withStyles(styles)(DataTable); diff --git a/src/components/main-app-bar/dropdown-menu/dropdown-menu.test.tsx b/src/components/dropdown-menu/dropdown-menu.test.tsx similarity index 100% rename from src/components/main-app-bar/dropdown-menu/dropdown-menu.test.tsx rename to src/components/dropdown-menu/dropdown-menu.test.tsx diff --git a/src/components/main-app-bar/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx similarity index 100% rename from src/components/main-app-bar/dropdown-menu/dropdown-menu.tsx rename to src/components/dropdown-menu/dropdown-menu.tsx diff --git a/src/components/popover/helpers.ts b/src/components/popover/helpers.ts new file mode 100644 index 00000000..13f74a68 --- /dev/null +++ b/src/components/popover/helpers.ts @@ -0,0 +1,24 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { PopoverOrigin } from "@material-ui/core/Popover"; + +export const mockAnchorFromMouseEvent = (event: React.MouseEvent) => { + const el = document.createElement('div'); + const clientRect = { + left: event.clientX, + right: event.clientX, + top: event.clientY, + bottom: event.clientY, + width: 0, + height: 0 + }; + el.getBoundingClientRect = () => clientRect; + return el; +}; + +export const DefaultTransformOrigin: PopoverOrigin = { + vertical: "top", + horizontal: "right", +}; \ No newline at end of file diff --git a/src/components/popover/popover.test.tsx b/src/components/popover/popover.test.tsx new file mode 100644 index 00000000..fa24c0cd --- /dev/null +++ b/src/components/popover/popover.test.tsx @@ -0,0 +1,69 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure } from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; + +import Popover, { DefaultTrigger } from "./popover"; +import Button, { ButtonProps } from "@material-ui/core/Button"; + +configure({ adapter: new Adapter() }); + +describe("", () => { + it("opens on default trigger click", () => { + const popover = mount(); + popover.find(DefaultTrigger).simulate("click"); + expect(popover.state().anchorEl).toBeDefined(); + }); + + it("renders custom trigger", () => { + const popover = mount(); + expect(popover.find(Button).text()).toBe("Open popover"); + }); + + it("opens on custom trigger click", () => { + const popover = mount(); + popover.find(CustomTrigger).simulate("click"); + expect(popover.state().anchorEl).toBeDefined(); + }); + + it("renders children when opened", () => { + const popover = mount( + + + + ); + popover.find(DefaultTrigger).simulate("click"); + expect(popover.find(CustomTrigger)).toHaveLength(1); + }); + + it("does not close if closeOnContentClick is not set", () => { + const popover = mount( + + + + ); + popover.find(DefaultTrigger).simulate("click"); + popover.find(CustomTrigger).simulate("click"); + expect(popover.state().anchorEl).toBeDefined(); + }); + it("closes on content click if closeOnContentClick is set", () => { + const popover = mount( + + + + ); + popover.find(DefaultTrigger).simulate("click"); + popover.find(CustomTrigger).simulate("click"); + expect(popover.state().anchorEl).toBeUndefined(); + }); + +}); + +const CustomTrigger: React.SFC = (props) => ( + +); \ No newline at end of file diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx new file mode 100644 index 00000000..c8d40338 --- /dev/null +++ b/src/components/popover/popover.tsx @@ -0,0 +1,69 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Popover as MaterialPopover } from '@material-ui/core'; + +import { PopoverOrigin } from '@material-ui/core/Popover'; +import IconButton, { IconButtonProps } from '@material-ui/core/IconButton'; + +export interface PopoverProps { + triggerComponent?: React.ComponentType<{ onClick: (event: React.MouseEvent) => void }>; + closeOnContentClick?: boolean; +} + + +class Popover extends React.Component { + + state = { + anchorEl: undefined + }; + + transformOrigin: PopoverOrigin = { + vertical: "top", + horizontal: "right", + }; + + render() { + const Trigger = this.props.triggerComponent || DefaultTrigger; + return ( + <> + + + {this.props.children} + + + ); + } + + handleClose = () => { + this.setState({ anchorEl: undefined }); + } + + handleTriggerClick = (event: React.MouseEvent) => { + this.setState({ anchorEl: event.currentTarget }); + } + + handleSelfClick = () => { + if (this.props.closeOnContentClick) { + this.handleClose(); + } + } + +} + +export const DefaultTrigger: React.SFC = (props) => ( + + + +); + +export default Popover; diff --git a/src/components/main-app-bar/search-bar/search-bar.test.tsx b/src/components/search-bar/search-bar.test.tsx similarity index 100% rename from src/components/main-app-bar/search-bar/search-bar.test.tsx rename to src/components/search-bar/search-bar.test.tsx diff --git a/src/components/main-app-bar/search-bar/search-bar.tsx b/src/components/search-bar/search-bar.tsx similarity index 100% rename from src/components/main-app-bar/search-bar/search-bar.tsx rename to src/components/search-bar/search-bar.tsx diff --git a/src/components/search-input/search-input.test.tsx b/src/components/search-input/search-input.test.tsx new file mode 100644 index 00000000..b07445a5 --- /dev/null +++ b/src/components/search-input/search-input.test.tsx @@ -0,0 +1,99 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { mount, configure } from "enzyme"; +import SearchInput, { DEFAULT_SEARCH_DEBOUNCE } from "./search-input"; + +import * as Adapter from 'enzyme-adapter-react-16'; + +configure({ adapter: new Adapter() }); + +describe("", () => { + + jest.useFakeTimers(); + + let onSearch: () => void; + + beforeEach(() => { + onSearch = jest.fn(); + }); + + describe("on submit", () => { + it("calls onSearch with initial value passed via props", () => { + const searchInput = mount(); + searchInput.find("form").simulate("submit"); + expect(onSearch).toBeCalledWith("initial value"); + }); + + it("calls onSearch with current value", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + searchInput.find("form").simulate("submit"); + expect(onSearch).toBeCalledWith("current value"); + }); + + it("calls onSearch with new value passed via props", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + searchInput.setProps({value: "new value"}); + searchInput.find("form").simulate("submit"); + expect(onSearch).toBeCalledWith("new value"); + }); + + it("cancels timeout set on input value change", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + searchInput.find("form").simulate("submit"); + jest.advanceTimersByTime(1000); + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch).toBeCalledWith("current value"); + }); + + }); + + describe("on input value change", () => { + it("calls onSearch after default timeout", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + expect(onSearch).not.toBeCalled(); + jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE); + expect(onSearch).toBeCalledWith("current value"); + }); + + it("calls onSearch after the time specified in props has passed", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + jest.advanceTimersByTime(1000); + expect(onSearch).not.toBeCalled(); + jest.advanceTimersByTime(1000); + expect(onSearch).toBeCalledWith("current value"); + }); + + it("calls onSearch only once after no change happened during the specified time", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + jest.advanceTimersByTime(500); + searchInput.find("input").simulate("change", { target: { value: "changed value" } }); + jest.advanceTimersByTime(1000); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it("calls onSearch again after the specified time has passed since previous call", () => { + const searchInput = mount(); + searchInput.find("input").simulate("change", { target: { value: "current value" } }); + jest.advanceTimersByTime(500); + searchInput.find("input").simulate("change", { target: { value: "intermediate value" } }); + jest.advanceTimersByTime(1000); + expect(onSearch).toBeCalledWith("intermediate value"); + searchInput.find("input").simulate("change", { target: { value: "latest value" } }); + jest.advanceTimersByTime(1000); + expect(onSearch).toBeCalledWith("latest value"); + expect(onSearch).toHaveBeenCalledTimes(2); + + }); + + }); + +}); \ No newline at end of file diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx new file mode 100644 index 00000000..edc82d55 --- /dev/null +++ b/src/components/search-input/search-input.tsx @@ -0,0 +1,113 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, FormHelperText } from '@material-ui/core'; +import SearchIcon from '@material-ui/icons/Search'; + +interface SearchInputDataProps { + value: string; +} + +interface SearchInputActionProps { + onSearch: (value: string) => any; + debounce?: number; +} + +type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles; + +interface SearchInputState { + value: string; +} + +export const DEFAULT_SEARCH_DEBOUNCE = 1000; + +class SearchInput extends React.Component { + + state: SearchInputState = { + value: "" + }; + + timeout: number; + + render() { + const { classes } = this.props; + return
+ + Search + + + + + + } /> + +
; + } + + componentDidMount() { + this.setState({ value: this.props.value }); + } + + componentWillReceiveProps(nextProps: SearchInputProps) { + if (nextProps.value !== this.props.value) { + this.setState({ value: nextProps.value }); + } + } + + componentWillUnmount() { + clearTimeout(this.timeout); + } + + handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + clearTimeout(this.timeout); + this.props.onSearch(this.state.value); + } + + handleChange = (event: React.ChangeEvent) => { + clearTimeout(this.timeout); + this.setState({ value: event.target.value }); + this.timeout = window.setTimeout( + () => this.props.onSearch(this.state.value), + this.props.debounce || DEFAULT_SEARCH_DEBOUNCE + ); + + } + +} + +type CssRules = 'container' | 'input' | 'button'; + +const styles: StyleRulesCallback = theme => { + return { + container: { + position: 'relative', + width: '100%' + }, + input: { + border: 'none', + borderRadius: theme.spacing.unit / 4, + boxSizing: 'border-box', + padding: theme.spacing.unit, + paddingRight: theme.spacing.unit * 4, + width: '100%', + }, + button: { + position: 'absolute', + top: theme.spacing.unit / 2, + right: theme.spacing.unit / 2, + width: theme.spacing.unit * 3, + height: theme.spacing.unit * 3 + } + }; +}; + +export default withStyles(styles)(SearchInput); \ No newline at end of file diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index 5dd45878..2c19a831 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -30,7 +30,7 @@ interface TreeProps { items?: Array>; render: (item: TreeItem, level?: number) => ReactElement<{}>; toggleItemOpen: (id: string, status: TreeItemStatus) => void; - toggleItemActive: (id: string) => void; + toggleItemActive: (id: string, status: TreeItemStatus) => void; level?: number; } @@ -50,7 +50,7 @@ class Tree extends React.Component & WithStyles, {}> { return {items && items.map((it: TreeItem, idx: number) =>
- toggleItemActive(it.id)}> + toggleItemActive(it.id, it.status)}> {it.status === TreeItemStatus.Pending ? : null} {it.toggled && it.items && it.items.length === 0 ? null : this.renderArrow(it.status, it.active ? activeArrow : inactiveArrow, it.open, it.id)} {render(it, level)} diff --git a/src/index.tsx b/src/index.tsx index 1807bd8d..ba395e8b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,10 +11,10 @@ import { Route } from "react-router"; import createBrowserHistory from "history/createBrowserHistory"; import configureStore from "./store/store"; import { ConnectedRouter } from "react-router-redux"; -import ApiToken from "./components/api-token/api-token"; +import ApiToken from "./views-components/api-token/api-token"; import authActions from "./store/auth/auth-action"; -import { authService, projectService } from "./services/services"; -import { sidePanelData } from './store/side-panel/side-panel-reducer'; +import { authService } from "./services/services"; +import { getProjectList } from "./store/project/project-action"; const history = createBrowserHistory(); @@ -32,7 +32,7 @@ const store = configureStore({ store.dispatch(authActions.INIT()); const rootUuid = authService.getRootUuid(); -store.dispatch(projectService.getProjectList(rootUuid)); +store.dispatch(getProjectList(rootUuid)); const App = () => diff --git a/src/models/collection.ts b/src/models/collection.ts new file mode 100644 index 00000000..316b1fac --- /dev/null +++ b/src/models/collection.ts @@ -0,0 +1,8 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Resource } from "./resource"; + +export interface Collection extends Resource { +} diff --git a/src/models/project.ts b/src/models/project.ts index 83fb59bd..7d29de87 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -2,11 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -export interface Project { - name: string; - createdAt: string; - modifiedAt: string; - uuid: string; - ownerUuid: string; - href: string; +import { Resource } from "./resource"; + +export interface Project extends Resource { } diff --git a/src/models/resource.ts b/src/models/resource.ts new file mode 100644 index 00000000..39b4e915 --- /dev/null +++ b/src/models/resource.ts @@ -0,0 +1,9 @@ +export interface Resource { + name: string; + createdAt: string; + modifiedAt: string; + uuid: string; + ownerUuid: string; + href: string; + kind: string; +} diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 5878dc6e..d71f0299 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -4,8 +4,6 @@ import { API_HOST, serverApi } from "../../common/api/server-api"; import { User } from "../../models/user"; -import { Dispatch } from "redux"; -import actions from "../../store/auth/auth-action"; export const API_TOKEN_KEY = 'apiToken'; export const USER_EMAIL_KEY = 'userEmail'; @@ -79,13 +77,16 @@ export default class AuthService { window.location.assign(`${API_HOST}/logout?return_to=${currentUrl}`); } - public getUserDetails = () => (dispatch: Dispatch): Promise => { - dispatch(actions.USER_DETAILS_REQUEST()); + public getUserDetails = (): Promise => { return serverApi .get('/users/current') - .then(resp => { - dispatch(actions.USER_DETAILS_SUCCESS(resp.data)); - }); + .then(resp => ({ + email: resp.data.email, + firstName: resp.data.first_name, + lastName: resp.data.last_name, + uuid: resp.data.uuid, + ownerUuid: resp.data.owner_uuid + })); } public getRootUuid() { diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts new file mode 100644 index 00000000..171cd856 --- /dev/null +++ b/src/services/collection-service/collection-service.ts @@ -0,0 +1,53 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { serverApi } from "../../common/api/server-api"; +import FilterBuilder, { FilterField } from "../../common/api/filter-builder"; +import { ArvadosResource } from "../response"; +import { Collection } from "../../models/collection"; + +interface CollectionResource extends ArvadosResource { + name: string; + description: string; + properties: any; + portable_data_hash: string; + manifest_text: string; + replication_desired: number; + replication_confirmed: number; + replication_confirmed_at: string; + trash_at: string; + delete_at: string; + is_trashed: boolean; +} + +interface CollectionsResponse { + offset: number; + limit: number; + items: CollectionResource[]; +} + +export default class CollectionService { + public getCollectionList = (parentUuid?: string): Promise => { + if (parentUuid) { + const fb = new FilterBuilder(); + fb.addLike(FilterField.OWNER_UUID, parentUuid); + return serverApi.get('/collections', { params: { + filters: fb.get() + }}).then(resp => { + const collections = resp.data.items.map(g => ({ + name: g.name, + createdAt: g.created_at, + modifiedAt: g.modified_at, + href: g.href, + uuid: g.uuid, + ownerUuid: g.owner_uuid, + kind: g.kind + } as Collection)); + return collections; + }); + } else { + return Promise.resolve([]); + } + } +} diff --git a/src/services/project-service/project-service.ts b/src/services/project-service/project-service.ts index bb3d0713..bc340818 100644 --- a/src/services/project-service/project-service.ts +++ b/src/services/project-service/project-service.ts @@ -4,56 +4,46 @@ import { serverApi } from "../../common/api/server-api"; import { Dispatch } from "redux"; -import actions from "../../store/project/project-action"; import { Project } from "../../models/project"; -import UrlBuilder from "../../common/api/url-builder"; import FilterBuilder, { FilterField } from "../../common/api/filter-builder"; +import { ArvadosResource } from "../response"; + +interface GroupResource extends ArvadosResource { + name: string; + group_class: string; + description: string; + writable_by: string[]; + delete_at: string; + trash_at: string; + is_trashed: boolean; +} interface GroupsResponse { offset: number; limit: number; - items: Array<{ - href: string; - kind: string; - etag: string; - uuid: string; - owner_uuid: string; - created_at: string; - modified_by_client_uuid: string; - modified_by_user_uuid: string; - modified_at: string; - name: string; - group_class: string; - description: string; - writable_by: string[]; - delete_at: string; - trash_at: string; - is_trashed: boolean; - }>; + items: GroupResource[]; } export default class ProjectService { - public getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise => { - dispatch(actions.PROJECTS_REQUEST(parentUuid)); + public getProjectList = (parentUuid?: string): Promise => { if (parentUuid) { const fb = new FilterBuilder(); fb.addLike(FilterField.OWNER_UUID, parentUuid); return serverApi.get('/groups', { params: { filters: fb.get() - }}).then(groups => { - const projects = groups.data.items.map(g => ({ + }}).then(resp => { + const projects = resp.data.items.map(g => ({ name: g.name, createdAt: g.created_at, modifiedAt: g.modified_at, href: g.href, uuid: g.uuid, - ownerUuid: g.owner_uuid + ownerUuid: g.owner_uuid, + kind: g.kind } as Project)); - dispatch(actions.PROJECTS_SUCCESS({projects, parentItemId: parentUuid})); return projects; }); } else { - dispatch(actions.PROJECTS_SUCCESS({projects: [], parentItemId: parentUuid})); return Promise.resolve([]); } } diff --git a/src/services/response.ts b/src/services/response.ts new file mode 100644 index 00000000..a71282b9 --- /dev/null +++ b/src/services/response.ts @@ -0,0 +1,11 @@ +export interface ArvadosResource { + uuid: string; + owner_uuid: string; + created_at: string; + modified_by_client_uuid: string; + modified_by_user_uuid: string; + modified_at: string; + href: string; + kind: string; + etag: string; +} diff --git a/src/services/services.ts b/src/services/services.ts index ea72001a..47a24b34 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -4,6 +4,8 @@ import AuthService from "./auth-service/auth-service"; import ProjectService from "./project-service/project-service"; +import CollectionService from "./collection-service/collection-service"; export const authService = new AuthService(); export const projectService = new ProjectService(); +export const collectionService = new CollectionService(); diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index e18c78b1..a6e6f797 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -3,7 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0 import { ofType, default as unionize, UnionOf } from "unionize"; -import { UserDetailsResponse } from "../../services/auth-service/auth-service"; +import { Dispatch } from "redux"; +import { authService } from "../../services/services"; +import { User } from "../../models/user"; const actions = unionize({ SAVE_API_TOKEN: ofType(), @@ -11,11 +13,20 @@ const actions = unionize({ LOGOUT: {}, INIT: {}, USER_DETAILS_REQUEST: {}, - USER_DETAILS_SUCCESS: ofType() + USER_DETAILS_SUCCESS: ofType() }, { tag: 'type', value: 'payload' }); +export const getUserDetails = () => (dispatch: Dispatch): Promise => { + dispatch(actions.USER_DETAILS_REQUEST()); + return authService.getUserDetails().then(details => { + dispatch(actions.USER_DETAILS_SUCCESS(details)); + return details; + }); +}; + + export type AuthAction = UnionOf; export default actions; diff --git a/src/store/auth/auth-reducer.test.ts b/src/store/auth/auth-reducer.test.ts index a60e82a6..2e7c1a24 100644 --- a/src/store/auth/auth-reducer.test.ts +++ b/src/store/auth/auth-reducer.test.ts @@ -68,16 +68,15 @@ describe('auth-reducer', () => { it('should set user details on success fetch', () => { const initialState = undefined; - const userDetails = { + const user = { email: "test@test.com", - first_name: "John", - last_name: "Doe", + firstName: "John", + lastName: "Doe", uuid: "uuid", - owner_uuid: "ownerUuid", - is_admin: true + ownerUuid: "ownerUuid" }; - const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(userDetails)); + const state = authReducer(initialState, actions.USER_DETAILS_SUCCESS(user)); expect(state).toEqual({ apiToken: undefined, user: { diff --git a/src/store/auth/auth-reducer.ts b/src/store/auth/auth-reducer.ts index 02b9d30c..f6974fd2 100644 --- a/src/store/auth/auth-reducer.ts +++ b/src/store/auth/auth-reducer.ts @@ -6,7 +6,6 @@ import actions, { AuthAction } from "./auth-action"; import { User } from "../../models/user"; import { authService } from "../../services/services"; import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api"; -import { UserDetailsResponse } from "../../services/auth-service/auth-service"; export interface AuthState { user?: User; @@ -39,14 +38,7 @@ const authReducer = (state: AuthState = {}, action: AuthAction) => { authService.logout(); return {...state, apiToken: undefined}; }, - USER_DETAILS_SUCCESS: (ud: UserDetailsResponse) => { - const user = { - email: ud.email, - firstName: ud.first_name, - lastName: ud.last_name, - uuid: ud.uuid, - ownerUuid: ud.owner_uuid - }; + USER_DETAILS_SUCCESS: (user: User) => { authService.saveUser(user); return {...state, user}; }, diff --git a/src/store/collection/collection-action.ts b/src/store/collection/collection-action.ts new file mode 100644 index 00000000..f50e6458 --- /dev/null +++ b/src/store/collection/collection-action.ts @@ -0,0 +1,29 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Collection } from "../../models/collection"; +import { default as unionize, ofType, UnionOf } from "unionize"; +import { Dispatch } from "redux"; +import { collectionService } from "../../services/services"; + +const actions = unionize({ + CREATE_COLLECTION: ofType(), + REMOVE_COLLECTION: ofType(), + COLLECTIONS_REQUEST: ofType(), + COLLECTIONS_SUCCESS: ofType<{ collections: Collection[] }>(), +}, { + tag: 'type', + value: 'payload' +}); + +export const getCollectionList = (parentUuid?: string) => (dispatch: Dispatch): Promise => { + dispatch(actions.COLLECTIONS_REQUEST()); + return collectionService.getCollectionList(parentUuid).then(collections => { + dispatch(actions.COLLECTIONS_SUCCESS({collections})); + return collections; + }); +}; + +export type CollectionAction = UnionOf; +export default actions; diff --git a/src/store/collection/collection-reducer.test.ts b/src/store/collection/collection-reducer.test.ts new file mode 100644 index 00000000..7b57ba72 --- /dev/null +++ b/src/store/collection/collection-reducer.test.ts @@ -0,0 +1,41 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import collectionsReducer from "./collection-reducer"; +import actions from "./collection-action"; + +describe('collection-reducer', () => { + it('should add new collection to the list', () => { + const initialState = undefined; + const collection = { + name: 'test', + href: 'href', + createdAt: '2018-01-01', + modifiedAt: '2018-01-01', + ownerUuid: 'owner-test123', + uuid: 'test123', + kind: "" + }; + + const state = collectionsReducer(initialState, actions.CREATE_COLLECTION(collection)); + expect(state).toEqual([collection]); + }); + + it('should load collections', () => { + const initialState = undefined; + const collection = { + name: 'test', + href: 'href', + createdAt: '2018-01-01', + modifiedAt: '2018-01-01', + ownerUuid: 'owner-test123', + uuid: 'test123', + kind: "" + }; + + const collections = [collection, collection]; + const state = collectionsReducer(initialState, actions.COLLECTIONS_SUCCESS({ collections })); + expect(state).toEqual([collection, collection]); + }); +}); diff --git a/src/store/collection/collection-reducer.ts b/src/store/collection/collection-reducer.ts new file mode 100644 index 00000000..939ca625 --- /dev/null +++ b/src/store/collection/collection-reducer.ts @@ -0,0 +1,25 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import actions, { CollectionAction } from "./collection-action"; +import { Collection } from "../../models/collection"; + +export type CollectionState = Collection[]; + + +const collectionsReducer = (state: CollectionState = [], action: CollectionAction) => { + return actions.match(action, { + CREATE_COLLECTION: collection => [...state, collection], + REMOVE_COLLECTION: () => state, + COLLECTIONS_REQUEST: () => { + return state; + }, + COLLECTIONS_SUCCESS: ({ collections }) => { + return collections; + }, + default: () => state + }); +}; + +export default collectionsReducer; diff --git a/src/store/project/project-action.ts b/src/store/project/project-action.ts index a58edd3c..3c264d3e 100644 --- a/src/store/project/project-action.ts +++ b/src/store/project/project-action.ts @@ -4,19 +4,31 @@ import { default as unionize, ofType, UnionOf } from "unionize"; import { Project } from "../../models/project"; +import { projectService } from "../../services/services"; +import { Dispatch } from "redux"; const actions = unionize({ CREATE_PROJECT: ofType(), REMOVE_PROJECT: ofType(), - PROJECTS_REQUEST: ofType(), + PROJECTS_REQUEST: ofType(), PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(), TOGGLE_PROJECT_TREE_ITEM_OPEN: ofType(), TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType(), RESET_PROJECT_TREE_ACTIVITY: ofType(), }, { - tag: 'type', - value: 'payload' -}); + tag: 'type', + value: 'payload' + }); + +export const getProjectList = (parentUuid?: string) => (dispatch: Dispatch): Promise => { + if (parentUuid) { + dispatch(actions.PROJECTS_REQUEST(parentUuid)); + return projectService.getProjectList(parentUuid).then(projects => { + dispatch(actions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid })); + return projects; + }); + } return Promise.resolve([]); +}; export type ProjectAction = UnionOf; export default actions; diff --git a/src/store/project/project-reducer.test.ts b/src/store/project/project-reducer.test.ts index e5cd57e2..e8d6afc6 100644 --- a/src/store/project/project-reducer.test.ts +++ b/src/store/project/project-reducer.test.ts @@ -2,8 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0 -import projectsReducer from "./project-reducer"; +import projectsReducer, { getTreePath } from "./project-reducer"; import actions from "./project-action"; +import { TreeItem, TreeItemStatus } from "../../components/tree/tree"; describe('project-reducer', () => { it('should add new project to the list', () => { @@ -14,7 +15,8 @@ describe('project-reducer', () => { createdAt: '2018-01-01', modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', - uuid: 'test123' + uuid: 'test123', + kind: "" }; const state = projectsReducer(initialState, actions.CREATE_PROJECT(project)); @@ -29,7 +31,8 @@ describe('project-reducer', () => { createdAt: '2018-01-01', modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', - uuid: 'test123' + uuid: 'test123', + kind: "" }; const projects = [project, project]; @@ -62,6 +65,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: true, @@ -78,6 +82,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: true, @@ -100,6 +105,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: true, @@ -116,6 +122,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: true, @@ -139,6 +146,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: true, @@ -156,6 +164,7 @@ describe('project-reducer', () => { modifiedAt: '2018-01-01', ownerUuid: 'owner-test123', uuid: 'test123', + kind: 'example' }, id: "1", open: false, @@ -169,3 +178,53 @@ describe('project-reducer', () => { expect(state).toEqual(project); }); }); + +describe("findTreeBranch", () => { + + const createTreeItem = (id: string, items?: Array>): TreeItem => ({ + id, + items, + active: false, + data: "", + open: false, + status: TreeItemStatus.Initial + }); + + it("should return an array that matches path to the given item", () => { + const tree: Array> = [ + createTreeItem("1", [ + createTreeItem("1.1", [ + createTreeItem("1.1.1"), + createTreeItem("1.1.2") + ]) + ]), + createTreeItem("2", [ + createTreeItem("2.1", [ + createTreeItem("2.1.1"), + createTreeItem("2.1.2") + ]) + ]) + ]; + const branch = getTreePath(tree, "2.1.1"); + expect(branch.map(item => item.id)).toEqual(["2", "2.1", "2.1.1"]); + }); + + it("should return empty array if item is not found", () => { + const tree: Array> = [ + createTreeItem("1", [ + createTreeItem("1.1", [ + createTreeItem("1.1.1"), + createTreeItem("1.1.2") + ]) + ]), + createTreeItem("2", [ + createTreeItem("2.1", [ + createTreeItem("2.1.1"), + createTreeItem("2.1.2") + ]) + ]) + ]; + expect(getTreePath(tree, "3")).toHaveLength(0); + }); + +}); diff --git a/src/store/project/project-reducer.ts b/src/store/project/project-reducer.ts index 43117ef0..48db05df 100644 --- a/src/store/project/project-reducer.ts +++ b/src/store/project/project-reducer.ts @@ -10,7 +10,7 @@ import { TreeItem, TreeItemStatus } from "../../components/tree/tree"; export type ProjectState = Array>; -function findTreeItem(tree: Array>, itemId: string): TreeItem | undefined { +export function findTreeItem(tree: Array>, itemId: string): TreeItem | undefined { let item; for (const t of tree) { item = t.id === itemId @@ -23,6 +23,20 @@ function findTreeItem(tree: Array>, itemId: string): TreeItem return item; } +export function getTreePath(tree: Array>, itemId: string): Array> { + for(const item of tree){ + if(item.id === itemId){ + return [item]; + } else { + const branch = getTreePath(item.items || [], itemId); + if(branch.length > 0){ + return [item, ...branch]; + } + } + } + return []; +} + function resetTreeActivity(tree: Array>) { for (const t of tree) { t.active = false; diff --git a/src/store/store.ts b/src/store/store.ts index 49e0b5f7..6089caf3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -10,6 +10,7 @@ import { History } from "history"; import projectsReducer, { ProjectState } from "./project/project-reducer"; import sidePanelReducer, { SidePanelState } from './side-panel/side-panel-reducer'; import authReducer, { AuthState } from "./auth/auth-reducer"; +import collectionsReducer from "./collection/collection-reducer"; const composeEnhancers = (process.env.NODE_ENV === 'development' && @@ -26,6 +27,7 @@ export interface RootState { const rootReducer = combineReducers({ auth: authReducer, projects: projectsReducer, + collections: collectionsReducer, router: routerReducer, sidePanel: sidePanelReducer }); diff --git a/src/components/api-token/api-token.tsx b/src/views-components/api-token/api-token.tsx similarity index 77% rename from src/components/api-token/api-token.tsx rename to src/views-components/api-token/api-token.tsx index 7656bf87..e4ba4914 100644 --- a/src/components/api-token/api-token.tsx +++ b/src/views-components/api-token/api-token.tsx @@ -5,8 +5,9 @@ import { Redirect, RouteProps } from "react-router"; import * as React from "react"; import { connect, DispatchProp } from "react-redux"; -import authActions from "../../store/auth/auth-action"; -import { authService, projectService } from "../../services/services"; +import authActions, { getUserDetails } from "../../store/auth/auth-action"; +import { authService } from "../../services/services"; +import { getProjectList } from "../../store/project/project-action"; interface ApiTokenProps { } @@ -23,9 +24,9 @@ class ApiToken extends React.Component(authService.getUserDetails()).then(() => { + this.props.dispatch(getUserDetails()).then(() => { const rootUuid = authService.getRootUuid(); - this.props.dispatch(projectService.getProjectList(rootUuid)); + this.props.dispatch(getProjectList(rootUuid)); }); } render() { diff --git a/src/components/main-app-bar/main-app-bar.test.tsx b/src/views-components/main-app-bar/main-app-bar.test.tsx similarity index 95% rename from src/components/main-app-bar/main-app-bar.test.tsx rename to src/views-components/main-app-bar/main-app-bar.test.tsx index f08c9392..25494b65 100644 --- a/src/components/main-app-bar/main-app-bar.test.tsx +++ b/src/views-components/main-app-bar/main-app-bar.test.tsx @@ -6,9 +6,9 @@ import * as React from "react"; import { mount, configure, ReactWrapper } from "enzyme"; import * as Adapter from "enzyme-adapter-react-16"; import MainAppBar from "./main-app-bar"; -import SearchBar from "./search-bar/search-bar"; -import Breadcrumbs from "../breadcrumbs/breadcrumbs"; -import DropdownMenu from "./dropdown-menu/dropdown-menu"; +import SearchBar from "../../components/search-bar/search-bar"; +import Breadcrumbs from "../../components/breadcrumbs/breadcrumbs"; +import DropdownMenu from "../../components/dropdown-menu/dropdown-menu"; import { Button, MenuItem, IconButton } from "@material-ui/core"; import { User } from "../../models/user"; @@ -98,4 +98,4 @@ describe("", () => { mainAppBar.find(DropdownMenu).at(0).find(MenuItem).at(1).simulate("click"); expect(onMenuItemClick).toBeCalledWith(menuItems.accountMenu[0]); }); -}); \ No newline at end of file +}); diff --git a/src/components/main-app-bar/main-app-bar.tsx b/src/views-components/main-app-bar/main-app-bar.tsx similarity index 94% rename from src/components/main-app-bar/main-app-bar.tsx rename to src/views-components/main-app-bar/main-app-bar.tsx index 27cd8bd4..c0525a56 100644 --- a/src/components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -7,9 +7,9 @@ import { AppBar, Toolbar, Typography, Grid, IconButton, Badge, StyleRulesCallbac import NotificationsIcon from "@material-ui/icons/Notifications"; import PersonIcon from "@material-ui/icons/Person"; import HelpIcon from "@material-ui/icons/Help"; -import SearchBar from "./search-bar/search-bar"; -import Breadcrumbs, { Breadcrumb } from "../breadcrumbs/breadcrumbs"; -import DropdownMenu from "./dropdown-menu/dropdown-menu"; +import SearchBar from "../../components/search-bar/search-bar"; +import Breadcrumbs, { Breadcrumb } from "../../components/breadcrumbs/breadcrumbs"; +import DropdownMenu from "../../components/dropdown-menu/dropdown-menu"; import { User, getUserFullname } from "../../models/user"; export interface MainAppBarMenuItem { @@ -126,4 +126,4 @@ const styles: StyleRulesCallback = theme => ({ } }); -export default withStyles(styles)(MainAppBar); \ No newline at end of file +export default withStyles(styles)(MainAppBar); diff --git a/src/views-components/project-explorer/project-explorer-item.ts b/src/views-components/project-explorer/project-explorer-item.ts new file mode 100644 index 00000000..055c22cf --- /dev/null +++ b/src/views-components/project-explorer/project-explorer-item.ts @@ -0,0 +1,13 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export interface ProjectExplorerItem { + uuid: string; + name: string; + type: string; + owner: string; + lastModified: string; + fileSize?: number; + status?: string; +} diff --git a/src/views-components/project-explorer/project-explorer.tsx b/src/views-components/project-explorer/project-explorer.tsx new file mode 100644 index 00000000..4931c09a --- /dev/null +++ b/src/views-components/project-explorer/project-explorer.tsx @@ -0,0 +1,224 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { ProjectExplorerItem } from './project-explorer-item'; +import { Grid, Typography } from '@material-ui/core'; +import { formatDate, formatFileSize } from '../../common/formatters'; +import DataExplorer from '../../components/data-explorer/data-explorer'; +import { DataColumn, toggleSortDirection, resetSortDirection } from '../../components/data-table/data-column'; +import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters'; +import { ContextMenuAction } from '../../components/context-menu/context-menu'; + +export interface ProjectExplorerContextActions { + onAddToFavourite: (item: ProjectExplorerItem) => void; + onCopy: (item: ProjectExplorerItem) => void; + onDownload: (item: ProjectExplorerItem) => void; + onMoveTo: (item: ProjectExplorerItem) => void; + onRemove: (item: ProjectExplorerItem) => void; + onRename: (item: ProjectExplorerItem) => void; + onShare: (item: ProjectExplorerItem) => void; +} + +interface ProjectExplorerProps { + items: ProjectExplorerItem[]; +} + +interface ProjectExplorerState { + columns: Array>; + searchValue: string; + page: number; + rowsPerPage: number; +} + +class ProjectExplorer extends React.Component { + state: ProjectExplorerState = { + searchValue: "", + page: 0, + rowsPerPage: 10, + columns: [{ + name: "Name", + selected: true, + sortDirection: "asc", + render: renderName + }, { + name: "Status", + selected: true, + filters: [{ + name: "In progress", + selected: true + }, { + name: "Complete", + selected: true + }], + render: renderStatus + }, { + name: "Type", + selected: true, + filters: [{ + name: "Collection", + selected: true + }, { + name: "Group", + selected: true + }], + render: item => renderType(item.type) + }, { + name: "Owner", + selected: true, + render: item => renderOwner(item.owner) + }, { + name: "File size", + selected: true, + sortDirection: "none", + render: item => renderFileSize(item.fileSize) + }, { + name: "Last modified", + selected: true, + render: item => renderDate(item.lastModified) + }] + }; + + contextMenuActions = [[{ + icon: "fas fa-users fa-fw", + name: "Share" + }, { + icon: "fas fa-sign-out-alt fa-fw", + name: "Move to" + }, { + icon: "fas fa-star fa-fw", + name: "Add to favourite" + }, { + icon: "fas fa-edit fa-fw", + name: "Rename" + }, { + icon: "fas fa-copy fa-fw", + name: "Make a copy" + }, { + icon: "fas fa-download fa-fw", + name: "Download" + }], [{ + icon: "fas fa-trash-alt fa-fw", + name: "Remove" + } + ]]; + + render() { + return ; + } + + toggleColumn = (toggledColumn: DataColumn) => { + this.setState({ + columns: this.state.columns.map(column => + column.name === toggledColumn.name + ? { ...column, selected: !column.selected } + : column + ) + }); + } + + toggleSort = (toggledColumn: DataColumn) => { + this.setState({ + columns: this.state.columns.map(column => + column.name === toggledColumn.name + ? toggleSortDirection(column) + : resetSortDirection(column) + ) + }); + } + + changeFilters = (filters: DataTableFilterItem[], updatedColumn: DataColumn) => { + this.setState({ + columns: this.state.columns.map(column => + column.name === updatedColumn.name + ? { ...column, filters } + : column + ) + }); + } + + executeAction = (action: ContextMenuAction, item: ProjectExplorerItem) => { + alert(`Executing ${action.name} on ${item.name}`); + } + + search = (searchValue: string) => { + this.setState({ searchValue }); + } + + changePage = (page: number) => { + this.setState({ page }); + } + + changeRowsPerPage = (rowsPerPage: number) => { + this.setState({ rowsPerPage }); + } +} + +const renderName = (item: ProjectExplorerItem) => + + + {renderIcon(item)} + + + + {item.name} + + + ; + +const renderIcon = (item: ProjectExplorerItem) => { + switch (item.type) { + case "arvados#group": + return ; + case "arvados#groupList": + return ; + default: + return ; + } +}; + +const renderDate = (date: string) => + + {formatDate(date)} + ; + +const renderFileSize = (fileSize?: number) => + + {formatFileSize(fileSize)} + ; + +const renderOwner = (owner: string) => + + {owner} + ; + +const renderType = (type: string) => + + {type} + ; + +const renderStatus = (item: ProjectExplorerItem) => + + {item.status || "-"} + ; + +export default ProjectExplorer; diff --git a/src/components/project-list/project-list.tsx b/src/views-components/project-list/project-list.tsx similarity index 100% rename from src/components/project-list/project-list.tsx rename to src/views-components/project-list/project-list.tsx diff --git a/src/components/project-tree/project-tree.test.tsx b/src/views-components/project-tree/project-tree.test.tsx similarity index 89% rename from src/components/project-tree/project-tree.test.tsx rename to src/views-components/project-tree/project-tree.test.tsx index 932a29cc..1ba3abb8 100644 --- a/src/components/project-tree/project-tree.test.tsx +++ b/src/views-components/project-tree/project-tree.test.tsx @@ -11,7 +11,7 @@ import { Collapse } from '@material-ui/core'; import CircularProgress from '@material-ui/core/CircularProgress'; import ProjectTree from './project-tree'; -import { TreeItem } from '../tree/tree'; +import { TreeItem } from '../../components/tree/tree'; import { Project } from '../../models/project'; Enzyme.configure({ adapter: new Adapter() }); @@ -26,13 +26,14 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: true, active: true, status: 1 }; - const wrapper = mount( { }} />); + const wrapper = mount(); expect(wrapper.find(ListItemIcon)).toHaveLength(1); }); @@ -47,6 +48,7 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: false, @@ -61,6 +63,7 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: false, @@ -68,7 +71,7 @@ describe("ProjectTree component", () => { status: 1 } ]; - const wrapper = mount( { }} />); + const wrapper = mount(); expect(wrapper.find(ListItemIcon)).toHaveLength(2); }); @@ -83,6 +86,7 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: true, @@ -97,6 +101,7 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: true, @@ -106,7 +111,7 @@ describe("ProjectTree component", () => { ] } ]; - const wrapper = mount( { }} />); + const wrapper = mount(); expect(wrapper.find(Collapse)).toHaveLength(1); }); @@ -120,13 +125,14 @@ describe("ProjectTree component", () => { uuid: "uuid", ownerUuid: "ownerUuid", href: "href", + kind: 'example' }, id: "3", open: false, active: true, status: 1 }; - const wrapper = mount( { }} />); + const wrapper = mount(); expect(wrapper.find(CircularProgress)).toHaveLength(1); }); diff --git a/src/components/project-tree/project-tree.tsx b/src/views-components/project-tree/project-tree.tsx similarity index 94% rename from src/components/project-tree/project-tree.tsx rename to src/views-components/project-tree/project-tree.tsx index 7406f7f3..f51b65e0 100644 --- a/src/components/project-tree/project-tree.tsx +++ b/src/views-components/project-tree/project-tree.tsx @@ -9,13 +9,13 @@ import ListItemText from "@material-ui/core/ListItemText/ListItemText"; import ListItemIcon from '@material-ui/core/ListItemIcon'; import Typography from '@material-ui/core/Typography'; -import Tree, { TreeItem, TreeItemStatus } from '../tree/tree'; +import Tree, { TreeItem, TreeItemStatus } from '../../components/tree/tree'; import { Project } from '../../models/project'; export interface ProjectTreeProps { projects: Array>; toggleOpen: (id: string, status: TreeItemStatus) => void; - toggleActive: (id: string) => void; + toggleActive: (id: string, status: TreeItemStatus) => void; } class ProjectTree extends React.Component> { diff --git a/src/views/project-panel/project-panel-selectors.ts b/src/views/project-panel/project-panel-selectors.ts new file mode 100644 index 00000000..610f2fa9 --- /dev/null +++ b/src/views/project-panel/project-panel-selectors.ts @@ -0,0 +1,15 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { TreeItem } from "../../components/tree/tree"; +import { Project } from "../../models/project"; +import { ProjectExplorerItem } from "../../views-components/project-explorer/project-explorer-item"; + +export const mapProjectTreeItem = (item: TreeItem): ProjectExplorerItem => ({ + name: item.data.name, + type: item.data.kind, + owner: item.data.ownerUuid, + lastModified: item.data.modifiedAt, + uuid: item.data.uuid +}); diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx new file mode 100644 index 00000000..f9e6c8b8 --- /dev/null +++ b/src/views/project-panel/project-panel.tsx @@ -0,0 +1,34 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { DispatchProp, connect } from 'react-redux'; +import { ProjectState, findTreeItem } from '../../store/project/project-reducer'; +import ProjectExplorer from '../../views-components/project-explorer/project-explorer'; +import { RootState } from '../../store/store'; +import { mapProjectTreeItem } from './project-panel-selectors'; + +interface ProjectPanelDataProps { + projects: ProjectState; +} + +type ProjectPanelProps = ProjectPanelDataProps & RouteComponentProps<{ name: string }> & DispatchProp; + +class ProjectPanel extends React.Component { + + render() { + const project = findTreeItem(this.props.projects, this.props.match.params.name); + const projectItems = project && project.items || []; + return ( + + ); + } +} + +export default connect( + (state: RootState) => ({ + projects: state.projects + }) +)(ProjectPanel); diff --git a/src/views/workbench/workbench.test.tsx b/src/views/workbench/workbench.test.tsx index 7b9b74d0..69257922 100644 --- a/src/views/workbench/workbench.test.tsx +++ b/src/views/workbench/workbench.test.tsx @@ -14,7 +14,7 @@ const history = createBrowserHistory(); it('renders without crashing', () => { const div = document.createElement('div'); - const store = configureStore({ projects: [], router: { location: null }, auth: {} }, createBrowserHistory()); + const store = configureStore({ projects: [], router: { location: null }, auth: {}, sidePanel: [] }, createBrowserHistory()); ReactDOM.render( diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 9e274325..4f9843cb 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -6,26 +6,27 @@ import * as React from 'react'; import { StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core/styles'; import Drawer from '@material-ui/core/Drawer'; import { connect, DispatchProp } from "react-redux"; - -import ProjectList from "../../components/project-list/project-list"; import { Route, Switch } from "react-router"; import authActions from "../../store/auth/auth-action"; import { User } from "../../models/user"; import { RootState } from "../../store/store"; -import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../components/main-app-bar/main-app-bar'; +import MainAppBar, { MainAppBarActionProps, MainAppBarMenuItem } from '../../views-components/main-app-bar/main-app-bar'; import { Breadcrumb } from '../../components/breadcrumbs/breadcrumbs'; import { push } from 'react-router-redux'; -import projectActions from "../../store/project/project-action"; -import sidePanelActions from '../../store/side-panel/side-panel-action'; -import ProjectTree from '../../components/project-tree/project-tree'; +import projectActions, { getProjectList } from "../../store/project/project-action"; +import ProjectTree from '../../views-components/project-tree/project-tree'; import { TreeItem, TreeItemStatus } from "../../components/tree/tree"; import { Project } from "../../models/project"; +import { getTreePath } from '../../store/project/project-reducer'; +import ProjectPanel from '../project-panel/project-panel'; +import sidePanelActions from '../../store/side-panel/side-panel-action'; import { projectService } from '../../services/services'; import SidePanel, { SidePanelItem } from '../../components/side-panel/side-panel'; const drawerWidth = 240; +const appBarHeight = 102; -type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'toolbar'; +type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar'; const styles: StyleRulesCallback = (theme: Theme) => ({ root: { @@ -49,12 +50,17 @@ const styles: StyleRulesCallback = (theme: Theme) => ({ display: 'flex', flexDirection: 'column', }, - content: { - flexGrow: 1, + contentWrapper: { backgroundColor: theme.palette.background.default, - padding: theme.spacing.unit * 3, - height: '100%', + display: "flex", + flexGrow: 1, minWidth: 0, + paddingTop: appBarHeight + }, + content: { + padding: theme.spacing.unit * 3, + overflowY: "auto", + flexGrow: 1 }, toolbar: theme.mixins.toolbar }); @@ -71,7 +77,8 @@ interface WorkbenchActionProps { type WorkbenchProps = WorkbenchDataProps & WorkbenchActionProps & DispatchProp & WithStyles; interface NavBreadcrumb extends Breadcrumb { - path: string; + itemId: string; + status: TreeItemStatus; } interface NavMenuItem extends MainAppBarMenuItem { @@ -93,15 +100,7 @@ class Workbench extends React.Component { state = { anchorEl: null, searchText: "", - breadcrumbs: [ - { - label: "Projects", - path: "/projects" - }, { - label: "Project 1", - path: "/projects/project-1" - } - ], + breadcrumbs: [], menuItems: { accountMenu: [ { @@ -130,7 +129,9 @@ class Workbench extends React.Component { mainAppBarActions: MainAppBarActionProps = { - onBreadcrumbClick: (breadcrumb: NavBreadcrumb) => this.props.dispatch(push(breadcrumb.path)), + onBreadcrumbClick: ({ itemId, status }: NavBreadcrumb) => { + this.toggleProjectTreeItemOpen(itemId, status); + }, onSearch: searchText => { this.setState({ searchText }); this.props.dispatch(push(`/search?q=${searchText}`)); @@ -140,19 +141,32 @@ class Workbench extends React.Component { toggleProjectTreeItemOpen = (itemId: string, status: TreeItemStatus) => { if (status === TreeItemStatus.Loaded) { + this.openProjectItem(itemId); this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId)); this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); } else { - this.props.dispatch(projectService.getProjectList(itemId)).then(() => { - this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId)); - this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); - }); + this.props.dispatch(getProjectList(itemId)) + .then(() => { + this.openProjectItem(itemId); + this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(itemId)); + this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); + }); } } - toggleProjectTreeItemActive = (itemId: string) => { - this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); - this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId)); + toggleProjectTreeItemActive = (itemId: string, status: TreeItemStatus) => { + if (status === TreeItemStatus.Loaded) { + this.openProjectItem(itemId); + this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); + this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId)); + } else { + this.props.dispatch(getProjectList(itemId)) + .then(() => { + this.openProjectItem(itemId); + this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); + this.props.dispatch(sidePanelActions.RESET_SIDE_PANEL_ACTIVITY(itemId)); + }); + } } toggleSidePanelOpen = (itemId: string) => { @@ -164,6 +178,19 @@ class Workbench extends React.Component { this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId)); } + openProjectItem = (itemId: string) => { + const branch = getTreePath(this.props.projects, itemId); + this.setState({ + breadcrumbs: branch.map(item => ({ + label: item.data.name, + itemId: item.data.uuid, + status: item.status + })) + }); + this.props.dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(itemId)); + this.props.dispatch(push(`/project/${itemId}`)); + } + render() { const { classes, user, projects, sidePanelItems } = this.props; return ( @@ -184,21 +211,22 @@ class Workbench extends React.Component { paper: classes.drawerPaper, }}>
- - - + + + } -
-
- - - +
+
+ + + +
); diff --git a/yarn.lock b/yarn.lock index eee6c860..9a3379b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -65,6 +65,10 @@ version "0.22.7" resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.7.tgz#4a92eafedfb2b9f4437d3a4410006d81114c66ce" +"@types/classnames@^2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.4.tgz#d3ee9ebf714aa34006707b8f4a58fd46b642305a" + "@types/enzyme-adapter-react-16@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.2.tgz#15ae37c64d6221a6f4b3a4aacc357cf773859de4" @@ -145,7 +149,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16.3.18": +"@types/react@*", "@types/react@16.3": version "16.3.18" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.3.18.tgz#bf195aed4d77dc86f06e4c9bb760214a3b822b8d" dependencies: @@ -1559,7 +1563,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5: +classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -2007,10 +2011,14 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": dependencies: cssom "0.3.x" -csstype@^2.0.0, csstype@^2.2.0, csstype@^2.5.2: +csstype@^2.0.0, csstype@^2.5.2: version "2.5.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.3.tgz#2504152e6e1cc59b32098b7f5d6a63f16294c1f7" +csstype@^2.2.0: + version "2.5.5" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.5.tgz#4125484a3d42189a863943f23b9e4b80fedfa106" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"