REACT_APP_ARVADOS_CONFIG_URL=/config.json
REACT_APP_ARVADOS_API_HOST=qr1hi.arvadosapi.com
-REACT_APP_ARVADOS_KEEP_WEB_HOST=download.qr1hi.arvadosapi.com
+REACT_APP_ARVADOS_KEEP_WEB_HOST=collections.qr1hi.arvadosapi.com
HTTPS=true
\ No newline at end of file
{items.map((group, groupIndex) =>
<React.Fragment key={groupIndex}>
{group.map((item, actionIndex) =>
- <ListItem
- button
- key={actionIndex}
- onClick={() => onItemClick(item)}>
- {item.icon &&
- <ListItemIcon>
- <item.icon />
- </ListItemIcon>}
- {item.name &&
- <ListItemText>
- {item.name}
- </ListItemText>}
- {item.component &&
- <item.component />}
- </ListItem>)}
+ item.component
+ ? <item.component
+ key={actionIndex}
+ onClick={() => onItemClick(item)} />
+ : <ListItem
+ button
+ key={actionIndex}
+ onClick={() => onItemClick(item)}>
+ {item.icon &&
+ <ListItemIcon>
+ <item.icon />
+ </ListItemIcon>}
+ {item.name &&
+ <ListItemText>
+ {item.name}
+ </ListItemText>}
+ </ListItem>)}
{groupIndex < items.length - 1 && <Divider />}
</React.Fragment>)}
</List>
//
// SPDX-License-Identifier: AGPL-3.0
-import { Tree } from './tree';
+import { Tree, createTree, setNode } from './tree';
export type CollectionFilesTree = Tree<CollectionDirectory | CollectionFile>;
export interface CollectionDirectory {
path: string;
+ url: string;
id: string;
name: string;
type: CollectionFileType.DIRECTORY;
export interface CollectionFile {
path: string;
+ url: string;
id: string;
name: string;
size: number;
id: '',
name: '',
path: '',
+ url: '',
type: CollectionFileType.DIRECTORY,
...data
});
id: '',
name: '',
path: '',
+ url: '',
size: 0,
type: CollectionFileType.FILE,
...data
});
+
+export const createCollectionFilesTree = (data: Array<CollectionDirectory | CollectionFile>) => {
+ const directories = data.filter(item => item.type === CollectionFileType.DIRECTORY);
+ directories.sort((a, b) => a.path.localeCompare(b.path));
+ const files = data.filter(item => item.type === CollectionFileType.FILE);
+ return [...directories, ...files]
+ .reduce((tree, item) => setNode({
+ children: [],
+ id: item.id,
+ parent: item.path,
+ value: item
+ })(tree), createTree<CollectionDirectory | CollectionFile>());
+};
\ No newline at end of file
import axios, { AxiosInstance } from "axios";
import { KeepService } from "../keep-service/keep-service";
import { FilterBuilder } from "../../common/api/filter-builder";
-import { CollectionFile, createCollectionFile } from "../../models/collection-file";
+import { CollectionFile, createCollectionFile, createCollectionDirectory, createCollectionFilesTree } from "../../models/collection-file";
import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
import * as _ from "lodash";
import { KeepManifestStream } from "../../models/keep-manifest";
+import { WebDAV } from "../../common/webdav";
+import { AuthService } from "../auth-service/auth-service";
export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
export class CollectionService extends CommonResourceService<CollectionResource> {
- constructor(serverApi: AxiosInstance, private keepService: KeepService) {
+ constructor(serverApi: AxiosInstance, private keepService: KeepService, private webdavClient: WebDAV, private authService: AuthService) {
super(serverApi, "collections");
}
+ async files(uuid: string) {
+ const request = await this.webdavClient.propfind(`/c=${uuid}`);
+ if (request.responseXML != null) {
+ return createCollectionFilesTree(this.extractFilesData(request.responseXML));
+ }
+ return Promise.reject();
+ }
+
+ async deleteFile(collectionUuid: string, filePath: string){
+ return this.webdavClient.delete(`/c=${collectionUuid}${filePath}`);
+ }
+
+
+ extractFilesData(document: Document) {
+ return Array
+ .from(document.getElementsByTagName('D:response'))
+ .slice(1)
+ .map(element => {
+ const [displayNameElement] = Array.from(element.getElementsByTagName('D:displayname'));
+ const name = displayNameElement ? displayNameElement.innerHTML : undefined;
+
+ const [sizeElement] = Array.from(element.getElementsByTagName('D:getcontentlength'));
+ const size = sizeElement ? parseInt(sizeElement.innerHTML, 10) : 0;
+
+ const [hrefElement] = Array.from(element.getElementsByTagName('D:href'));
+ const pathname = hrefElement ? hrefElement.innerHTML : undefined;
+ const directory = pathname && pathname.replace(/\/c=[0-9a-zA-Z\-]*/, '').replace(`/${name || ''}`, '');
+
+ const href = this.webdavClient.defaults.baseURL + pathname + '?api_token=' + this.authService.getApiToken();
+
+ const data = {
+ url: href,
+ id: `${directory}/${name}`,
+ name,
+ path: directory,
+ };
+
+ const [resourceTypeElement] = Array.from(element.getElementsByTagName('D:resourcetype'));
+ return resourceTypeElement && resourceTypeElement.innerHTML === ''
+ ? createCollectionFile({ ...data, size })
+ : createCollectionDirectory(data);
+
+ });
+ }
+
+
private readFile(file: File): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>(resolve => {
const reader = new FileReader();
const projectService = new ProjectService(apiClient);
const linkService = new LinkService(apiClient);
const favoriteService = new FavoriteService(linkService, groupsService);
- const collectionService = new CollectionService(apiClient, keepService);
+ const collectionService = new CollectionService(apiClient, keepService, webdavClient, authService);
const tagService = new TagService(linkService);
const collectionFilesService = new CollectionFilesService(collectionService);
import { Dispatch } from "redux";
import { ResourceKind } from "../../models/resource";
import { CollectionResource } from "../../models/collection";
-import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions";
+import { collectionPanelFilesAction, loadCollectionFiles } from "./collection-panel-files/collection-panel-files-actions";
import { createTree } from "../../models/tree";
import { RootState } from "../store";
import { ServiceRepository } from "../../services/services";
import { snackbarActions } from "../snackbar/snackbar-actions";
export const collectionPanelActions = unionize({
- LOAD_COLLECTION: ofType<{ uuid: string, kind: ResourceKind }>(),
+ LOAD_COLLECTION: ofType<{ uuid: string }>(),
LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>(),
LOAD_COLLECTION_TAGS: ofType<{ uuid: string }>(),
LOAD_COLLECTION_TAGS_SUCCESS: ofType<{ tags: TagResource[] }>(),
export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm';
-export const loadCollection = (uuid: string, kind: ResourceKind) =>
+export const loadCollection = (uuid: string) =>
(dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid, kind }));
+ dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid }));
dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() }));
return services.collectionService
.get(uuid)
.then(item => {
dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item }));
- return services.collectionFilesService.getFiles(item.uuid);
- })
- .then(files => {
- dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(files));
+ dispatch<any>(loadCollectionFiles(uuid));
});
};
// SPDX-License-Identifier: AGPL-3.0
import { default as unionize, ofType, UnionOf } from "unionize";
-import { CollectionFilesTree } from "../../../models/collection-file";
+import { Dispatch } from "redux";
+import { CollectionFilesTree, CollectionFileType } from "../../../models/collection-file";
+import { ServiceRepository } from "../../../services/services";
+import { RootState } from "../../store";
+import { snackbarActions } from "../../snackbar/snackbar-actions";
+import { dialogActions } from "../../dialog/dialog-actions";
+import { getNodeValue, getNodeDescendants } from "../../../models/tree";
+import { CollectionPanelDirectory, CollectionPanelFile } from "./collection-panel-files-state";
export const collectionPanelFilesAction = unionize({
SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
}, { tag: 'type', value: 'payload' });
-export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
\ No newline at end of file
+export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
+
+export const loadCollectionFiles = (uuid: string) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const files = await services.collectionService.files(uuid);
+ dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(files));
+ };
+
+export const removeCollectionFiles = (filePaths: string[]) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const { item } = getState().collectionPanel;
+ if (item) {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+ const promises = filePaths.map(filePath => services.collectionService.deleteFile(item.uuid, filePath));
+ await Promise.all(promises);
+ dispatch<any>(loadCollectionFiles(item.uuid));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+ }
+ };
+
+export const removeCollectionsSelectedFiles = () =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const tree = getState().collectionPanelFiles;
+ const allFiles = getNodeDescendants('')(tree)
+ .map(id => getNodeValue(id)(tree))
+ .filter(file => file !== undefined) as Array<CollectionPanelDirectory | CollectionPanelFile>;
+
+ const selectedDirectories = allFiles.filter(file => file.selected && file.type === CollectionFileType.DIRECTORY);
+ const selectedFiles = allFiles.filter(file => file.selected && !selectedDirectories.some(dir => dir.id === file.path));
+ const paths = [...selectedDirectories, ...selectedFiles].map(file => file.id);
+ dispatch<any>(removeCollectionFiles(paths));
+ };
+
+export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
+export const openFileRemoveDialog = (filePath: string) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const file = getNodeValue(filePath)(getState().collectionPanelFiles);
+ if (file) {
+ const title = file.type === CollectionFileType.DIRECTORY
+ ? 'Removing directory'
+ : 'Removing file';
+ const text = file.type === CollectionFileType.DIRECTORY
+ ? 'Are you sure you want to remove this directory?'
+ : 'Are you sure you want to remove this file?';
+
+ dispatch(dialogActions.OPEN_DIALOG({
+ id: FILE_REMOVE_DIALOG,
+ data: {
+ title,
+ text,
+ confirmButtonLabel: 'Remove',
+ filePath
+ }
+ }));
+ }
+ };
+
+export const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
+export const openMultipleFilesRemoveDialog = () =>
+ dialogActions.OPEN_DIALOG({
+ id: MULTIPLE_FILES_REMOVE_DIALOG,
+ data: {
+ title: 'Removing files',
+ text: 'Are you sure you want to remove selected files?',
+ confirmButtonLabel: 'Remove'
+ }
+ });
\ No newline at end of file
// SPDX-License-Identifier: AGPL-3.0
import { ContextMenuActionSet } from "../context-menu-action-set";
-import { collectionPanelFilesAction } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
-import { openMultipleFilesRemoveDialog } from "../../file-remove-dialog/multiple-files-remove-dialog";
+import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
import { createCollectionWithSelected } from "../../create-collection-dialog-with-selected/create-collection-dialog-with-selected";
}
}, {
name: "Remove selected",
- execute: (dispatch, resource) => {
+ execute: (dispatch) => {
dispatch(openMultipleFilesRemoveDialog());
}
}, {
import { ContextMenuActionSet } from "../context-menu-action-set";
import { RenameIcon, DownloadIcon, RemoveIcon } from "../../../components/icon/icon";
import { openRenameFileDialog } from "../../rename-file-dialog/rename-file-dialog";
-import { openFileRemoveDialog } from "../../file-remove-dialog/file-remove-dialog";
+import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
+import { openFileRemoveDialog } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
dispatch<any>(openRenameFileDialog(resource.name));
}
}, {
- name: "Download",
- icon: DownloadIcon,
- execute: (dispatch, resource) => {
- return;
- }
+ component: DownloadCollectionFileAction,
+ execute: () => { return; }
}, {
name: "Remove",
icon: RemoveIcon,
execute: (dispatch, resource) => {
- dispatch(openFileRemoveDialog(resource.uuid));
+ dispatch<any>(openFileRemoveDialog(resource.uuid));
}
}]];
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { ListItemIcon, ListItemText, Button, ListItem } from "@material-ui/core";
+import { DownloadIcon } from "../../../components/icon/icon";
+
+export const DownloadAction = (props: { href?: string, download?: string, onClick?: () => void }) => {
+ const targetProps = props.download ? {} : { target: '_blank' };
+ const downloadProps = props.download ? { download: props.download } : {};
+ return props.href
+ ? <a
+ style={{ textDecoration: 'none' }}
+ href={props.href}
+ onClick={props.onClick}
+ {...targetProps}
+ {...downloadProps}>
+ <ListItem button>
+ <ListItemIcon>
+ <DownloadIcon />
+ </ListItemIcon>
+ <ListItemText>
+ Download
+ </ListItemText>
+ </ListItem>
+ </a >
+ : null;
+};
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { DownloadAction } from "./download-action";
+import { getNodeValue } from "../../../models/tree";
+import { CollectionFileType } from "../../../models/collection-file";
+
+const mapStateToProps = (state: RootState) => {
+ const { resource } = state.contextMenu;
+ if (resource) {
+ const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+ if (file) {
+ return {
+ href: file.url,
+ download: file.type === CollectionFileType.DIRECTORY ? undefined : file.name
+ };
+ }
+ }
+ return {};
+};
+
+export const DownloadCollectionFileAction = connect(mapStateToProps)(DownloadAction);
// SPDX-License-Identifier: AGPL-3.0
import * as React from "react";
-import { ListItemIcon, ListItemText } from "@material-ui/core";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
import { AddFavoriteIcon, RemoveFavoriteIcon } from "../../../components/icon/icon";
import { connect } from "react-redux";
import { RootState } from "../../../store/store";
-const mapStateToProps = (state: RootState) => ({
- isFavorite: state.contextMenu.resource !== undefined && state.favorites[state.contextMenu.resource.uuid] === true
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+ isFavorite: state.contextMenu.resource !== undefined && state.favorites[state.contextMenu.resource.uuid] === true,
+ onClick: props.onClick
});
-export const ToggleFavoriteAction = connect(mapStateToProps)((props: { isFavorite: boolean }) =>
- <>
+export const ToggleFavoriteAction = connect(mapStateToProps)((props: { isFavorite: boolean, onClick: () => void }) =>
+ <ListItem
+ button
+ onClick={props.onClick}>
<ListItemIcon>
{props.isFavorite
? <RemoveFavoriteIcon />
: <AddFavoriteIcon />}
</ListItemIcon>
- <ListItemText>
+ <ListItemText style={{ textDecoration: 'none' }}>
{props.isFavorite
? <>Remove from favorites</>
: <>Add to favorites</>}
</ListItemText>
- </>);
+ </ListItem >);
import { Dispatch } from "redux";
import { connect } from "react-redux";
import { ConfirmationDialog } from "../../components/confirmation-dialog/confirmation-dialog";
-import { withDialog } from "../../store/dialog/with-dialog";
-import { dialogActions } from "../../store/dialog/dialog-actions";
-import { snackbarActions } from "../../store/snackbar/snackbar-actions";
+import { withDialog, WithDialogProps } from "../../store/dialog/with-dialog";
+import { RootState } from "../../store/store";
+import { removeCollectionFiles, FILE_REMOVE_DIALOG } from "../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
-const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
+const mapStateToProps = (state: RootState, props: WithDialogProps<{ filePath: string }>) => ({
+ filePath: props.data.filePath
+});
-const mapDispatchToProps = (dispatch: Dispatch) => ({
- onConfirm: () => {
- // TODO: dispatch action that removes single file
- dispatch(dialogActions.CLOSE_DIALOG({ id: FILE_REMOVE_DIALOG }));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing file...', hideDuration: 2000 }));
- setTimeout(() => {
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File removed.', hideDuration: 2000 }));
- }, 1000);
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<{ filePath: string }>) => ({
+ onConfirm: (filePath: string) => {
+ props.closeDialog();
+ dispatch<any>(removeCollectionFiles([filePath]));
}
});
-export const openFileRemoveDialog = (fileId: string) =>
- dialogActions.OPEN_DIALOG({
- id: FILE_REMOVE_DIALOG,
- data: {
- title: 'Removing file',
- text: 'Are you sure you want to remove this file?',
- confirmButtonLabel: 'Remove',
- fileId
- }
+const mergeProps = (
+ stateProps: { filePath: string },
+ dispatchProps: { onConfirm: (filePath: string) => void },
+ props: WithDialogProps<{ filePath: string }>) => ({
+ onConfirm: () => dispatchProps.onConfirm(stateProps.filePath),
+ ...props
});
export const [FileRemoveDialog] = [ConfirmationDialog]
- .map(withDialog(FILE_REMOVE_DIALOG))
- .map(connect(undefined, mapDispatchToProps));
\ No newline at end of file
+ .map(connect(mapStateToProps, mapDispatchToProps, mergeProps))
+ .map(withDialog(FILE_REMOVE_DIALOG));
\ No newline at end of file
import { Dispatch } from "redux";
import { connect } from "react-redux";
import { ConfirmationDialog } from "../../components/confirmation-dialog/confirmation-dialog";
-import { withDialog } from "../../store/dialog/with-dialog";
-import { dialogActions } from "../../store/dialog/dialog-actions";
-import { snackbarActions } from "../../store/snackbar/snackbar-actions";
+import { withDialog, WithDialogProps } from "../../store/dialog/with-dialog";
+import { MULTIPLE_FILES_REMOVE_DIALOG, removeCollectionsSelectedFiles } from "../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
-const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
onConfirm: () => {
- // TODO: dispatch action that removes multiple files
- dispatch(dialogActions.CLOSE_DIALOG({ id: MULTIPLE_FILES_REMOVE_DIALOG }));
- dispatch(snackbarActions.OPEN_SNACKBAR({message: 'Removing files...', hideDuration: 2000}));
- setTimeout(() => {
- dispatch(snackbarActions.OPEN_SNACKBAR({message: 'Files removed.', hideDuration: 2000}));
- }, 1000);
+ props.closeDialog();
+ dispatch<any>(removeCollectionsSelectedFiles());
}
});
-export const openMultipleFilesRemoveDialog = () =>
- dialogActions.OPEN_DIALOG({
- id: MULTIPLE_FILES_REMOVE_DIALOG,
- data: {
- title: 'Removing files',
- text: 'Are you sure you want to remove selected files?',
- confirmButtonLabel: 'Remove'
- }
- });
-
export const [MultipleFilesRemoveDialog] = [ConfirmationDialog]
- .map(withDialog(MULTIPLE_FILES_REMOVE_DIALOG))
- .map(connect(undefined, mapDispatchToProps));
\ No newline at end of file
+ .map(connect(undefined, mapDispatchToProps))
+ .map(withDialog(MULTIPLE_FILES_REMOVE_DIALOG));
\ No newline at end of file
);
}
- renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
+ renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
onItemRouteChange={(collectionId) => {
- this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION));
+ this.props.dispatch<any>(loadCollection(collectionId));
this.props.dispatch<any>(loadCollectionTags(collectionId));
}}
onContextMenu={(event, item) => {
onItemDoubleClick={item => {
switch (item.kind) {
case ResourceKind.COLLECTION:
- this.props.dispatch(loadCollection(item.uuid, item.kind as ResourceKind));
+ this.props.dispatch(loadCollection(item.uuid));
this.props.dispatch(push(getCollectionUrl(item.uuid)));
- default:
+ default:
this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE));
this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
}
onItemDoubleClick={item => {
switch (item.kind) {
case ResourceKind.COLLECTION:
- this.props.dispatch(loadCollection(item.uuid, item.kind as ResourceKind));
+ this.props.dispatch(loadCollection(item.uuid));
this.props.dispatch(push(getCollectionUrl(item.uuid)));
default:
this.props.dispatch(loadDetails(item.uuid, ResourceKind.PROJECT));