From ab3e261d28ff83fa214002a372a055817a931cd1 Mon Sep 17 00:00:00 2001 From: Daniel Kos Date: Tue, 24 Jul 2018 00:18:41 +0200 Subject: [PATCH] Add favorite panel Feature #13753 Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- src/components/side-panel/side-panel.tsx | 1 + .../favorite-service/favorite-service.ts | 7 +- .../project-service/project-service.ts | 1 - .../favorite-panel-middleware.ts | 132 ++++++++++ src/store/navigation/navigation-action.ts | 5 + src/store/side-panel/side-panel-reducer.ts | 6 +- src/store/store.ts | 4 +- .../context-menu/context-menu.tsx | 3 +- .../favorite-panel/favorite-panel-item.ts | 31 +++ src/views/favorite-panel/favorite-panel.tsx | 225 ++++++++++++++++++ src/views/workbench/workbench.tsx | 22 +- 11 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 src/store/favorite-panel/favorite-panel-middleware.ts create mode 100644 src/views/favorite-panel/favorite-panel-item.ts create mode 100644 src/views/favorite-panel/favorite-panel.tsx diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx index 4240b1bf..0d275840 100644 --- a/src/components/side-panel/side-panel.tsx +++ b/src/components/side-panel/side-panel.tsx @@ -58,6 +58,7 @@ export interface SidePanelItem { open?: boolean; margin?: boolean; openAble?: boolean; + path?: string; } interface SidePanelDataProps { diff --git a/src/services/favorite-service/favorite-service.ts b/src/services/favorite-service/favorite-service.ts index d075b796..fe7c7874 100644 --- a/src/services/favorite-service/favorite-service.ts +++ b/src/services/favorite-service/favorite-service.ts @@ -10,9 +10,12 @@ import { ListArguments, ListResults } from "../../common/api/common-resource-ser import { OrderBuilder } from "../../common/api/order-builder"; export interface FavoriteListArguments extends ListArguments { + limit?: number; + offset?: number; filters?: FilterBuilder; order?: OrderBuilder; } + export class FavoriteService { constructor( private linkService: LinkService, @@ -63,6 +66,4 @@ export class FavoriteService { }); }); } - - -} \ No newline at end of file +} diff --git a/src/services/project-service/project-service.ts b/src/services/project-service/project-service.ts index f759547a..7b1640ee 100644 --- a/src/services/project-service/project-service.ts +++ b/src/services/project-service/project-service.ts @@ -32,5 +32,4 @@ export class ProjectService extends GroupsService { .create() .addEqual("groupClass", GroupClass.Project)); } - } diff --git a/src/store/favorite-panel/favorite-panel-middleware.ts b/src/store/favorite-panel/favorite-panel-middleware.ts new file mode 100644 index 00000000..e2743c26 --- /dev/null +++ b/src/store/favorite-panel/favorite-panel-middleware.ts @@ -0,0 +1,132 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Middleware } from "redux"; +import { dataExplorerActions } from "../data-explorer/data-explorer-action"; +import { favoriteService, groupsService } from "../../services/services"; +import { RootState } from "../store"; +import { getDataExplorer } from "../data-explorer/data-explorer-reducer"; +import { FilterBuilder } from "../../common/api/filter-builder"; +import { DataColumns } from "../../components/data-table/data-table"; +import { ProcessResource } from "../../models/process"; +import { OrderBuilder } from "../../common/api/order-builder"; +import { GroupContentsResource, GroupContentsResourcePrefix } from "../../services/groups-service/groups-service"; +import { SortDirection } from "../../components/data-table/data-column"; +import { + columns, + FAVORITE_PANEL_ID, + FavoritePanelColumnNames, + FavoritePanelFilter +} from "../../views/favorite-panel/favorite-panel"; +import { FavoritePanelItem, resourceToDataItem } from "../../views/favorite-panel/favorite-panel-item"; + +export const favoritePanelMiddleware: Middleware = store => next => { + next(dataExplorerActions.SET_COLUMNS({ id: FAVORITE_PANEL_ID, columns })); + + return action => { + + const handleProjectPanelAction = (handler: (data: T) => void) => + (data: T) => { + next(action); + if (data.id === FAVORITE_PANEL_ID) { + handler(data); + } + }; + + dataExplorerActions.match(action, { + SET_PAGE: handleProjectPanelAction(() => { + store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID })); + }), + SET_ROWS_PER_PAGE: handleProjectPanelAction(() => { + store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID })); + }), + SET_FILTERS: handleProjectPanelAction(() => { + store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: FAVORITE_PANEL_ID })); + store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID })); + }), + TOGGLE_SORT: handleProjectPanelAction(() => { + store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID })); + }), + SET_SEARCH_VALUE: handleProjectPanelAction(() => { + store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: FAVORITE_PANEL_ID })); + store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID })); + }), + REQUEST_ITEMS: handleProjectPanelAction(() => { + const state = store.getState() as RootState; + const dataExplorer = getDataExplorer(state.dataExplorer, FAVORITE_PANEL_ID); + const columns = dataExplorer.columns as DataColumns; + const typeFilters = getColumnFilters(columns, FavoritePanelColumnNames.TYPE); + const statusFilters = getColumnFilters(columns, FavoritePanelColumnNames.STATUS); + const sortColumn = dataExplorer.columns.find(({ sortDirection }) => Boolean(sortDirection && sortDirection !== "none")); + const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.Asc ? SortDirection.Asc : SortDirection.Desc; + if (typeFilters.length > 0) { + favoriteService + .list(state.projects.currentItemId, { + limit: dataExplorer.rowsPerPage, + offset: dataExplorer.page * dataExplorer.rowsPerPage, + order: /*sortColumn + ? sortColumn.name === FavoritePanelColumnNames.NAME + ? getOrder("name", sortDirection) + : getOrder("createdAt", sortDirection) + : */OrderBuilder.create(), + filters: FilterBuilder + .create() + .concat(FilterBuilder + .create() + .addIsA("uuid", typeFilters.map(f => f.type))) + .concat(FilterBuilder + .create(GroupContentsResourcePrefix.Process) + .addIn("state", statusFilters.map(f => f.type))) + .concat(getSearchFilter(dataExplorer.searchValue)) + }) + .then(response => { + store.dispatch(dataExplorerActions.SET_ITEMS({ + id: FAVORITE_PANEL_ID, + items: response.items.map(resourceToDataItem), + itemsAvailable: response.itemsAvailable, + page: Math.floor(response.offset / response.limit), + rowsPerPage: response.limit + })); + }); + } else { + store.dispatch(dataExplorerActions.SET_ITEMS({ + id: FAVORITE_PANEL_ID, + items: [], + itemsAvailable: 0, + page: 0, + rowsPerPage: dataExplorer.rowsPerPage + })); + } + }), + default: () => next(action) + }); + }; +}; + +const getColumnFilters = (columns: DataColumns, columnName: string) => { + const column = columns.find(c => c.name === columnName); + return column && column.filters ? column.filters.filter(f => f.selected) : []; +}; + +const getOrder = (attribute: "name" | "createdAt", direction: SortDirection) => + [ + OrderBuilder.create(GroupContentsResourcePrefix.Collection), + OrderBuilder.create(GroupContentsResourcePrefix.Process), + OrderBuilder.create(GroupContentsResourcePrefix.Project) + ].reduce((acc, b) => + acc.concat(direction === SortDirection.Asc + ? b.addAsc(attribute) + : b.addDesc(attribute)), OrderBuilder.create()); + +const getSearchFilter = (searchValue: string) => + searchValue + ? [ + FilterBuilder.create(GroupContentsResourcePrefix.Collection), + FilterBuilder.create(GroupContentsResourcePrefix.Process), + FilterBuilder.create(GroupContentsResourcePrefix.Project)] + .reduce((acc, b) => + acc.concat(b.addILike("name", searchValue)), FilterBuilder.create()) + : FilterBuilder.create(); + + diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 3920b5a2..7f782438 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -58,3 +58,8 @@ export const setProjectItem = (itemId: string, itemMode: ItemMode) => } }; + +export const setFavoriteItem = (itemId: string, itemMode: ItemMode) => + (dispatch: Dispatch, getState: () => RootState) => { + const a = 1; + }; diff --git a/src/store/side-panel/side-panel-reducer.ts b/src/store/side-panel/side-panel-reducer.ts index 2bbd6a11..5dd5c012 100644 --- a/src/store/side-panel/side-panel-reducer.ts +++ b/src/store/side-panel/side-panel-reducer.ts @@ -14,11 +14,12 @@ export const sidePanelReducer = (state: SidePanelState = sidePanelData, action: return sidePanelData; } else { return sidePanelActions.match(action, { - TOGGLE_SIDE_PANEL_ITEM_OPEN: itemId => state.map(it => itemId === it.id && it.open === false ? {...it, open: true} : {...it, open: false}), + TOGGLE_SIDE_PANEL_ITEM_OPEN: itemId => + state.map(it => ({...it, open: itemId === it.id && it.open === false})), TOGGLE_SIDE_PANEL_ITEM_ACTIVE: itemId => { const sidePanel = _.cloneDeep(state); resetSidePanelActivity(sidePanel); - sidePanel.map(it => { + sidePanel.forEach(it => { if (it.id === itemId) { it.active = true; } @@ -77,6 +78,7 @@ export const sidePanelData = [ name: "Favorites", icon: FavoriteIcon, active: false, + path: '/favorites' }, { id: SidePanelIdentifiers.Trash, diff --git a/src/store/store.ts b/src/store/store.ts index adb7ddde..fbb5ad61 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -14,6 +14,7 @@ import { dataExplorerReducer, DataExplorerState } from './data-explorer/data-exp import { projectPanelMiddleware } from './project-panel/project-panel-middleware'; import { detailsPanelReducer, DetailsPanelState } from './details-panel/details-panel-reducer'; import { contextMenuReducer, ContextMenuState } from './context-menu/context-menu-reducer'; +import { favoritePanelMiddleware } from "./favorite-panel/favorite-panel-middleware"; const composeEnhancers = (process.env.NODE_ENV === 'development' && @@ -45,7 +46,8 @@ export function configureStore(history: History) { const middlewares: Middleware[] = [ routerMiddleware(history), thunkMiddleware, - projectPanelMiddleware + projectPanelMiddleware, + favoritePanelMiddleware ]; const enhancer = composeEnhancers(applyMiddleware(...middlewares)); return createStore(rootReducer, enhancer); diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index cc2fcb31..fe6ebd81 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -56,5 +56,6 @@ const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet export enum ContextMenuKind { RootProject = "RootProject", - Project = "Project" + Project = "Project", + Favorite = "Favorite" } diff --git a/src/views/favorite-panel/favorite-panel-item.ts b/src/views/favorite-panel/favorite-panel-item.ts new file mode 100644 index 00000000..28f7f882 --- /dev/null +++ b/src/views/favorite-panel/favorite-panel-item.ts @@ -0,0 +1,31 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { GroupContentsResource } from "../../services/groups-service/groups-service"; +import { ResourceKind } from "../../models/resource"; + +export interface FavoritePanelItem { + uuid: string; + name: string; + kind: string; + url: string; + owner: string; + lastModified: string; + fileSize?: number; + status?: string; +} + + +export function resourceToDataItem(r: GroupContentsResource): FavoritePanelItem { + return { + uuid: r.uuid, + name: r.name, + kind: r.kind, + url: "", + owner: r.ownerUuid, + lastModified: r.modifiedAt, + status: r.kind === ResourceKind.Process ? r.state : undefined + }; +} + diff --git a/src/views/favorite-panel/favorite-panel.tsx b/src/views/favorite-panel/favorite-panel.tsx new file mode 100644 index 00000000..4c5b5bd8 --- /dev/null +++ b/src/views/favorite-panel/favorite-panel.tsx @@ -0,0 +1,225 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { FavoritePanelItem } from './favorite-panel-item'; +import { Grid, Typography, Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core'; +import { formatDate, formatFileSize } from '../../common/formatters'; +import { DataExplorer } from "../../views-components/data-explorer/data-explorer"; +import { DispatchProp, connect } from 'react-redux'; +import { DataColumns } from '../../components/data-table/data-table'; +import { RouteComponentProps } from 'react-router'; +import { RootState } from '../../store/store'; +import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters'; +import { ContainerRequestState } from '../../models/container-request'; +import { SortDirection } from '../../components/data-table/data-column'; +import { ResourceKind } from '../../models/resource'; +import { resourceLabel } from '../../common/labels'; +import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '../../components/icon/icon'; +import { ArvadosTheme } from '../../common/custom-theme'; + +type CssRules = "toolbar" | "button"; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + toolbar: { + paddingBottom: theme.spacing.unit * 3, + textAlign: "right" + }, + button: { + marginLeft: theme.spacing.unit + }, +}); + +const renderName = (item: FavoritePanelItem) => + + + {renderIcon(item)} + + + + {item.name} + + + ; + + +const renderIcon = (item: FavoritePanelItem) => { + switch (item.kind) { + case ResourceKind.Project: + return ; + case ResourceKind.Collection: + return ; + case ResourceKind.Process: + return ; + default: + return ; + } +}; + +const renderDate = (date: string) => { + return {formatDate(date)}; +}; + +const renderFileSize = (fileSize?: number) => + + {formatFileSize(fileSize)} + ; + +const renderOwner = (owner: string) => + + {owner} + ; + +const renderType = (type: string) => + + {resourceLabel(type)} + ; + +const renderStatus = (item: FavoritePanelItem) => + + {item.status || "-"} + ; + +export enum FavoritePanelColumnNames { + NAME = "Name", + STATUS = "Status", + TYPE = "Type", + OWNER = "Owner", + FILE_SIZE = "File size", + LAST_MODIFIED = "Last modified" +} + +export interface FavoritePanelFilter extends DataTableFilterItem { + type: ResourceKind | ContainerRequestState; +} + +export const columns: DataColumns = [ + { + name: FavoritePanelColumnNames.NAME, + selected: true, + sortDirection: SortDirection.Asc, + render: renderName, + width: "450px" + }, + { + name: "Status", + selected: true, + filters: [ + { + name: ContainerRequestState.Committed, + selected: true, + type: ContainerRequestState.Committed + }, + { + name: ContainerRequestState.Final, + selected: true, + type: ContainerRequestState.Final + }, + { + name: ContainerRequestState.Uncommitted, + selected: true, + type: ContainerRequestState.Uncommitted + } + ], + render: renderStatus, + width: "75px" + }, + { + name: FavoritePanelColumnNames.TYPE, + selected: true, + filters: [ + { + name: resourceLabel(ResourceKind.Collection), + selected: true, + type: ResourceKind.Collection + }, + { + name: resourceLabel(ResourceKind.Process), + selected: true, + type: ResourceKind.Process + }, + { + name: resourceLabel(ResourceKind.Project), + selected: true, + type: ResourceKind.Project + } + ], + render: item => renderType(item.kind), + width: "125px" + }, + { + name: FavoritePanelColumnNames.OWNER, + selected: true, + render: item => renderOwner(item.owner), + width: "200px" + }, + { + name: FavoritePanelColumnNames.FILE_SIZE, + selected: true, + render: item => renderFileSize(item.fileSize), + width: "50px" + }, + { + name: FavoritePanelColumnNames.LAST_MODIFIED, + selected: true, + sortDirection: SortDirection.None, + render: item => renderDate(item.lastModified), + width: "150px" + } +]; + +export const FAVORITE_PANEL_ID = "favoritePanel"; + +interface FavoritePanelDataProps { + currentItemId: string; +} + +interface FavoritePanelActionProps { + onItemClick: (item: FavoritePanelItem) => void; + onContextMenu: (event: React.MouseEvent, item: FavoritePanelItem) => void; + onDialogOpen: (ownerUuid: string) => void; + onItemDoubleClick: (item: FavoritePanelItem) => void; + onItemRouteChange: (itemId: string) => void; +} + +type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp + & WithStyles & RouteComponentProps<{ id: string }>; + +export const FavoritePanel = withStyles(styles)( + connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))( + class extends React.Component { + render() { + const { classes } = this.props; + return
+
+ + + +
+ item.uuid} /> +
; + } + + handleNewProjectClick = () => { + this.props.onDialogOpen(this.props.currentItemId); + } + componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: FavoritePanelProps) { + if (match.params.id !== currentItemId) { + onItemRouteChange(match.params.id); + } + } + } + ) +); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index a62b713a..3fec6d67 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -18,7 +18,7 @@ import { TreeItem } from "../../components/tree/tree"; import { getTreePath } from '../../store/project/project-reducer'; import { sidePanelActions } from '../../store/side-panel/side-panel-action'; import { SidePanel, SidePanelItem } from '../../components/side-panel/side-panel'; -import { ItemMode, setProjectItem } from "../../store/navigation/navigation-action"; +import { ItemMode, setFavoriteItem, setProjectItem } from "../../store/navigation/navigation-action"; import { projectActions } from "../../store/project/project-action"; import { ProjectPanel } from "../project-panel/project-panel"; import { DetailsPanel } from '../../views-components/details-panel/details-panel'; @@ -28,10 +28,11 @@ import { authService } from '../../services/services'; import { detailsPanelActions, loadDetails } from "../../store/details-panel/details-panel-action"; import { contextMenuActions } from "../../store/context-menu/context-menu-actions"; -import { SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer'; +import { sidePanelData, SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer'; import { ProjectResource } from '../../models/project'; import { ResourceKind } from '../../models/resource'; import { ContextMenu, ContextMenuKind } from "../../views-components/context-menu/context-menu"; +import { FavoritePanel } from "../favorite-panel/favorite-panel"; const drawerWidth = 240; const appBarHeight = 100; @@ -191,6 +192,7 @@ export const Workbench = withStyles(styles)(
+
{ user && } @@ -214,6 +216,19 @@ export const Workbench = withStyles(styles)( }} {...props} /> + renderFavoritePanel = (props: RouteComponentProps<{ id: string }>) => this.props.dispatch(setFavoriteItem(itemId, ItemMode.ACTIVE))} + onContextMenu={(event, item) => this.openContextMenu(event, item.uuid, ContextMenuKind.Favorite)} + onDialogOpen={this.handleCreationDialogOpen} + onItemClick={item => { + this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind)); + }} + onItemDoubleClick={item => { + this.props.dispatch(setFavoriteItem(item.uuid, ItemMode.ACTIVE)); + this.props.dispatch(loadDetails(item.uuid, ResourceKind.Project)); + }} + {...props} /> + mainAppBarActions: MainAppBarActionProps = { onBreadcrumbClick: ({ itemId }: NavBreadcrumb) => { this.props.dispatch(setProjectItem(itemId, ItemMode.BOTH)); @@ -239,7 +254,8 @@ export const Workbench = withStyles(styles)( toggleSidePanelActive = (itemId: string) => { this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(itemId)); this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId)); - this.props.dispatch(push("/")); + const panelItem = this.props.sidePanelItems.find(it => it.id === itemId); + this.props.dispatch(push(panelItem && panelItem.path ? panelItem.path : "/")); } handleCreationDialogOpen = (itemUuid: string) => { -- 2.39.5