errorToken: string;
}
+export enum CommonResourceServiceError {
+ UNIQUE_VIOLATION = 'UniqueViolation',
+ UNKNOWN = 'Unknown',
+ NONE = 'None'
+}
+
export class CommonResourceService<T extends Resource> {
static mapResponseKeys = (response: any): Promise<any> =>
}
}
+export const getCommonResourceServiceError = (errorResponse: any) => {
+ if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+ const error = errorResponse.errors.join('');
+ switch (true) {
+ case /UniqueViolation/.test(error):
+ return CommonResourceServiceError.UNIQUE_VIOLATION;
+ default:
+ return CommonResourceServiceError.UNKNOWN;
+ }
+ }
+ return CommonResourceServiceError.NONE;
+};
+
+
}
},
MuiInput: {
+ root: {
+ fontSize: '0.875rem'
+ },
underline: {
'&:after': {
borderBottomColor: purple800
}
},
MuiFormLabel: {
+ root: {
+ fontSize: '0.875rem'
+ },
focused: {
"&$focused:not($error)": {
color: purple800
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const fileToArrayBuffer = (file: File) =>
+ new Promise<ArrayBuffer>((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ resolve(reader.result as ArrayBuffer);
+ };
+ reader.onerror = () => {
+ reject();
+ };
+ reader.readAsArrayBuffer(file);
+ });
it('PUT', async () => {
const { open, send, load, progress, createRequest } = mockCreateRequest();
- const onProgress = jest.fn();
const webdav = new WebDAV(undefined, createRequest);
- const promise = webdav.put('foo', 'Test data', { onProgress });
+ const promise = webdav.put('foo', 'Test data');
progress();
load();
const request = await promise;
expect(open).toHaveBeenCalledWith('PUT', 'foo');
expect(send).toHaveBeenCalledWith('Test data');
- expect(onProgress).toHaveBeenCalled();
expect(request).toBeInstanceOf(XMLHttpRequest);
});
.keys(headers)
.forEach(key => r.setRequestHeader(key, headers[key]));
- if (config.onProgress) {
- r.addEventListener('progress', config.onProgress);
+ if (config.onUploadProgress) {
+ r.upload.addEventListener('progress', config.onUploadProgress);
}
r.addEventListener('load', () => resolve(r));
headers?: {
[key: string]: string;
};
- onProgress?: (event: ProgressEvent) => void;
+ onUploadProgress?: (event: ProgressEvent) => void;
}
interface WebDAVDefaults {
url: string;
headers?: { [key: string]: string };
data?: any;
- onProgress?: (event: ProgressEvent) => void;
+ onUploadProgress?: (event: ProgressEvent) => void;
}
import { FileTree } from '../file-tree/file-tree';
import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button } from '@material-ui/core';
import { CustomizeTableIcon } from '../icon/icon';
-import { connect, DispatchProp } from "react-redux";
-import { Dispatch } from "redux";
-import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
export interface CollectionPanelFilesProps {
items: Array<TreeItem<FileTreeData>>;
}
});
-const renameFile = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- services.collectionFilesService.renameTest();
-};
-
-
export const CollectionPanelFiles =
- connect()(
withStyles(styles)(
- ({ onItemMenuOpen, onOptionsMenuOpen, classes, dispatch, ...treeProps }: CollectionPanelFilesProps & DispatchProp & WithStyles<CssRules>) =>
- <Card className={classes.root}>
- <CardHeader
- title="Files"
- action={
- <Button onClick={
- () => {
- dispatch<any>(renameFile());
- }}
- variant='raised'
- color='primary'
- size='small'>
- Upload data
+ ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+ <Card className={classes.root}>
+ <CardHeader
+ title="Files"
+ action={
+ <Button onClick={onUploadDataClick}
+ variant='raised'
+ color='primary'
+ size='small'>
+ Upload data
</Button>
- } />
- <CardHeader
- className={classes.cardSubheader}
- action={
- <IconButton onClick={onOptionsMenuOpen}>
- <CustomizeTableIcon />
- </IconButton>
- } />
- <Grid container justify="space-between">
- <Typography variant="caption" className={classes.nameHeader}>
- Name
+ } />
+ <CardHeader
+ className={classes.cardSubheader}
+ action={
+ <IconButton onClick={onOptionsMenuOpen}>
+ <CustomizeTableIcon />
+ </IconButton>
+ } />
+ <Grid container justify="space-between">
+ <Typography variant="caption" className={classes.nameHeader}>
+ Name
</Typography>
- <Typography variant="caption" className={classes.fileSizeHeader}>
- File size
+ <Typography variant="caption" className={classes.fileSizeHeader}>
+ File size
</Typography>
- </Grid>
- <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
- </Card>)
-);
+ </Grid>
+ <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
+ </Card>);
import { ArvadosTheme } from '~/common/custom-theme';
import * as classnames from "classnames";
-type CssRules = 'attribute' | 'label' | 'value' | 'link';
+type CssRules = 'attribute' | 'label' | 'value' | 'lowercaseValue' | 'link';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
attribute: {
alignItems: 'flex-start',
textTransform: 'capitalize'
},
+ lowercaseValue: {
+ textTransform: 'lowercase'
+ },
link: {
width: '60%',
color: theme.palette.primary.main,
classLabel?: string;
value?: string | number;
classValue?: string;
+ lowercaseValue?: boolean;
link?: string;
children?: React.ReactNode;
}
type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules>;
export const DetailsAttribute = withStyles(styles)(
- ({ label, link, value, children, classes, classLabel, classValue }: DetailsAttributeProps) =>
+ ({ label, link, value, children, classes, classLabel, classValue, lowercaseValue }: DetailsAttributeProps) =>
<Typography component="div" className={classes.attribute}>
<Typography component="span" className={classnames([classes.label, classLabel])}>{label}</Typography>
{ link
? <a href={link} className={classes.link} target='_blank'>{value}</a>
- : <Typography component="span" className={classnames([classes.value, classValue])}>
+ : <Typography component="span" className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
{value}
{children}
</Typography> }
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { FileUpload } from "~/components/file-upload/file-upload";
+import { UploadFile } from '~/store/collections/uploader/collection-uploader-actions';
+import { Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core/';
+import { Button, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '../../store/dialog/with-dialog';
+
+export interface FilesUploadDialogProps {
+ files: UploadFile[];
+ uploading: boolean;
+ onSubmit: () => void;
+ onChange: (files: File[]) => void;
+}
+
+export const FilesUploadDialog = (props: FilesUploadDialogProps & WithDialogProps<{}>) =>
+ <Dialog open={props.open}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}
+ fullWidth={true}
+ maxWidth='sm'>
+ <DialogTitle>Upload data</DialogTitle>
+ <DialogContent>
+ <FileUpload
+ files={props.files}
+ disabled={props.uploading}
+ onDrop={props.onChange}
+ />
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ disabled={props.uploading}
+ onClick={props.closeDialog}>
+ Cancel
+ </Button>
+ <Button
+ variant='contained'
+ color='primary'
+ type='submit'
+ onClick={props.onSubmit}
+ disabled={props.uploading}>
+ {props.uploading
+ ? <CircularProgress size={20} />
+ : 'Upload data'}
+ </Button>
+ </DialogActions>
+ </Dialog>;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+
+type CssRules = "button" | "lastButton" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+ button: {
+ marginLeft: theme.spacing.unit
+ },
+ lastButton: {
+ marginLeft: theme.spacing.unit,
+ marginRight: "20px",
+ },
+ formContainer: {
+ display: "flex",
+ flexDirection: "column",
+ marginTop: "20px",
+ },
+ dialogTitle: {
+ paddingBottom: "0"
+ },
+ progressIndicator: {
+ position: "absolute",
+ minWidth: "20px",
+ },
+ dialogActions: {
+ marginBottom: "24px"
+ }
+});
+
+interface DialogProjectProps {
+ cancelLabel?: string;
+ dialogTitle: string;
+ formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
+ submitLabel?: string;
+}
+
+export const FormDialog = withStyles(styles)((props: DialogProjectProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>) =>
+ <Dialog
+ open={props.open}
+ onClose={props.closeDialog}
+ disableBackdropClick={props.submitting}
+ disableEscapeKeyDown={props.submitting}
+ fullWidth>
+ <form>
+ <DialogTitle className={props.classes.dialogTitle}>
+ {props.dialogTitle}
+ </DialogTitle>
+ <DialogContent className={props.classes.formContainer}>
+ <props.formFields {...props} />
+ </DialogContent>
+ <DialogActions className={props.classes.dialogActions}>
+ <Button
+ onClick={props.closeDialog}
+ className={props.classes.button}
+ color="primary"
+ disabled={props.submitting}>
+ {props.cancelLabel || 'Cancel'}
+ </Button>
+ <Button
+ onClick={props.handleSubmit}
+ className={props.classes.lastButton}
+ color="primary"
+ disabled={props.invalid || props.submitting || props.pristine}
+ variant="contained">
+ {props.submitLabel || 'Submit'}
+ {props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
+ </Button>
+ </DialogActions>
+ </form>
+ </Dialog>
+);
+
+
alignItems: 'center'
},
listItemText: {
- fontWeight: 700
+ fontWeight: 400
},
active: {
color: theme.palette.primary.main,
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress } from "@material-ui/core";
+
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+import { MOVE_TO_VALIDATION } from "~/validators/validators";
+
+export const MoveToDialog = (props: WithDialogProps<string> & InjectedFormProps<{ name: string }>) =>
+ <form>
+ <Dialog open={props.open}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}>
+ <DialogTitle>Move to</DialogTitle>
+ <DialogContent>
+ <Field
+ name="projectUuid"
+ component={Picker}
+ validate={MOVE_TO_VALIDATION} />
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ disabled={props.submitting}
+ onClick={props.closeDialog}>
+ Cancel
+ </Button>
+ <Button
+ variant='contained'
+ color='primary'
+ type='submit'
+ onClick={props.handleSubmit}
+ disabled={props.pristine || props.invalid || props.submitting}>
+ {props.submitting ? <CircularProgress size={20} /> : 'Move'}
+ </Button>
+ </DialogActions>
+ </Dialog>
+ </form>;
+
+const Picker = (props: WrappedFieldProps) =>
+ <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+ <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+ </div>;
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import * as React from "react";
+import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+import { MAKE_A_COPY_VALIDATION, COPY_NAME_VALIDATION } from "~/validators/validators";
+import { TextField } from '~/components/text-field/text-field';
+
+export interface CopyFormData {
+ name: string;
+ projectUuid: string;
+ uuid: string;
+}
+
+export const ProjectCopy = (props: WithDialogProps<string> & InjectedFormProps<CopyFormData>) =>
+ <form>
+ <Dialog open={props.open}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}>
+ <DialogTitle>Make a copy</DialogTitle>
+ <DialogContent>
+ <Field
+ name="name"
+ component={TextField}
+ validate={COPY_NAME_VALIDATION}
+ label="Enter a new name for the copy" />
+ <Field
+ name="projectUuid"
+ component={Picker}
+ validate={MAKE_A_COPY_VALIDATION} />
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ disabled={props.submitting}
+ onClick={props.closeDialog}>
+ Cancel
+ </Button>
+ <Button
+ variant='contained'
+ color='primary'
+ type='submit'
+ onClick={props.handleSubmit}
+ disabled={props.pristine || props.invalid || props.submitting}>
+ {props.submitting && <CircularProgress size={20} style={{position: 'absolute'}}/>}
+ Copy
+ </Button>
+ </DialogActions>
+ </Dialog>
+ </form>;
+const Picker = (props: WrappedFieldProps) =>
+ <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+ <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+ </div>;
\ No newline at end of file
import * as Adapter from 'enzyme-adapter-react-16';
import ListItem from "@material-ui/core/ListItem/ListItem";
-import { Tree, TreeItem } from './tree';
+import { Tree, TreeItem, TreeItemStatus } from './tree';
import { ProjectResource } from '../../models/project';
import { mockProjectResource } from '../../models/test-utils';
import { Checkbox } from '@material-ui/core';
id: "3",
open: true,
active: true,
- status: 1,
+ status: TreeItemStatus.LOADED
};
const wrapper = mount(<Tree
render={project => <div />}
id: "3",
open: true,
active: true,
- status: 1,
+ status: TreeItemStatus.LOADED,
};
const wrapper = mount(<Tree
render={project => <div />}
id: "3",
open: true,
active: true,
- status: 1,
+ status: TreeItemStatus.LOADED
};
const wrapper = mount(<Tree
showSelection={true}
id: "3",
open: true,
active: true,
- status: 1,
+ status: TreeItemStatus.LOADED,
};
const spy = jest.fn();
const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
});
export enum TreeItemStatus {
- INITIAL,
- PENDING,
- LOADED
+ INITIAL = 'initial',
+ PENDING = 'pending',
+ LOADED = 'loaded'
}
export interface TreeItem<T> {
<i onClick={() => this.props.toggleItemOpen(it.id, it.status)}
className={toggableIconContainer}>
<ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
- {it.status !== TreeItemStatus.INITIAL && it.items && it.items.length === 0 ? <span /> : <SidePanelRightArrowIcon />}
+ {this.getProperArrowAnimation(it.status, it.items!)}
</ListItemIcon>
</i>
{this.props.showSelection &&
</List>;
}
+ getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
+ return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon />;
+ }
+
+ isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
+ return status === TreeItemStatus.PENDING ||
+ (status === TreeItemStatus.LOADED && !items) ||
+ (status === TreeItemStatus.LOADED && items && items.length === 0);
+ }
+
getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
return classnames(toggableIcon, {
const store = configureStore(history, services);
store.dispatch(initAuth());
- store.dispatch(getProjectList(services.authService.getUuid()));
+ store.dispatch(getProjectList(services.authService.getUuid()));
const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props}/>;
const WorkbenchComponent = (props: any) => <Workbench authService={services.authService} buildInfo={buildInfo} {...props}/>;
{ children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
{ children: [], id: 'Node 3', parent: 'Node 2', value: 'Value 1' }
].reduce((tree, node) => Tree.setNode(node)(tree), tree);
- expect(Tree.getNodeAncestors('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+ expect(Tree.getNodeAncestorsIds('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
});
it('gets node descendants', () => {
{ children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
{ children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
].reduce((tree, node) => Tree.setNode(node)(tree), tree);
- expect(Tree.getNodeDescendants('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+ expect(Tree.getNodeDescendantsIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
});
it('gets root descendants', () => {
{ children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
{ children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
].reduce((tree, node) => Tree.setNode(node)(tree), tree);
- expect(Tree.getNodeDescendants('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+ expect(Tree.getNodeDescendantsIds('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
});
it('gets node children', () => {
{ children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
{ children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
].reduce((tree, node) => Tree.setNode(node)(tree), tree);
- expect(Tree.getNodeChildren('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+ expect(Tree.getNodeChildrenIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
});
it('gets root children', () => {
{ children: [], id: 'Node 3', parent: '', value: 'Value 1' },
{ children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
].reduce((tree, node) => Tree.setNode(node)(tree), tree);
- expect(Tree.getNodeChildren('')(newTree)).toEqual(['Node 1', 'Node 3']);
+ expect(Tree.getNodeChildrenIds('')(newTree)).toEqual(['Node 1', 'Node 3']);
});
it('maps tree', () => {
export const TREE_ROOT_ID = '';
-export interface TreeNode<T> {
+export interface TreeNode<T = any> {
children: string[];
value: T;
id: string;
const [newTree] = [tree]
.map(tree => getNode(node.id)(tree) === node
? tree
- : {...tree, [node.id]: node})
+ : { ...tree, [node.id]: node })
.map(addChild(node.parent, node.id));
return newTree;
};
};
export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
- getNodeDescendants('')(tree)
+ getNodeDescendantsIds('')(tree)
.map(id => getNode(id)(tree))
.map(mapNodeValue(mapFn))
.reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
-export const mapTree = <T, R>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
- getNodeDescendants('')(tree)
+export const mapTree = <T, R = T>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+ getNodeDescendantsIds('')(tree)
.map(id => getNode(id)(tree))
.map(mapFn)
.reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
-export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>): string[] => {
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>) =>
+ mapIdsToNodes(getNodeAncestorsIds(id)(tree))(tree);
+
+
+export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[] => {
const node = getNode(id)(tree);
return node && node.parent
- ? [...getNodeAncestors(node.parent)(tree), node.parent]
+ ? [...getNodeAncestorsIds(node.parent)(tree), node.parent]
: [];
};
-export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
+ mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
+
+export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
const node = getNode(id)(tree);
const children = node ? node.children :
id === TREE_ROOT_ID
.concat(limit < 1
? []
: children
- .map(id => getNodeDescendants(id, limit - 1)(tree))
+ .map(id => getNodeDescendantsIds(id, limit - 1)(tree))
.reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
};
-export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
- getNodeDescendants(id, 0)(tree);
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>) =>
+ mapIdsToNodes(getNodeChildrenIds(id)(tree))(tree);
+
+export const getNodeChildrenIds = (id: string) => <T>(tree: Tree<T>): string[] =>
+ getNodeDescendantsIds(id, 0)(tree);
+
+export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
+ ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
({ ...node, value: mapFn(node.value) });
import { uniqBy, groupBy } from 'lodash';
import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest";
-import { TreeNode, setNode, createTree, getNodeDescendants, getNodeValue } from '~/models/tree';
+import { TreeNode, setNode, createTree, getNodeDescendantsIds, getNodeValue } from '~/models/tree';
import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile, CollectionFileType } from '../../models/collection-file';
export const mapCollectionFilesTreeToManifest = (tree: CollectionFilesTree): KeepManifest => {
- const values = getNodeDescendants('')(tree).map(id => getNodeValue(id)(tree));
+ const values = getNodeDescendantsIds('')(tree).map(id => getNodeValue(id)(tree));
const files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[];
const fileGroups = groupBy(files, file => file.path);
return Object
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createCollectionFilesTree, CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { getTagValue } from "~/common/xml";
+import { getNodeChildren, Tree, mapTree } from '~/models/tree';
+
+export const parseFilesResponse = (document: Document) => {
+ const files = extractFilesData(document);
+ const tree = createCollectionFilesTree(files);
+ return sortFilesTree(tree);
+};
+
+export const sortFilesTree = (tree: Tree<CollectionDirectory | CollectionFile>) => {
+ return mapTree<CollectionDirectory | CollectionFile>(node => {
+ const children = getNodeChildren(node.id)(tree);
+
+ children.sort((a, b) =>
+ a.value.type !== b.value.type
+ ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
+ : a.value.name.localeCompare(b.value.name)
+ );
+ return { ...node, children: children.map(child => child.id) };
+ })(tree);
+};
+
+export const extractFilesData = (document: Document) => {
+ const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
+ return Array
+ .from(document.getElementsByTagName('D:response'))
+ .slice(1) // omit first element which is collection itself
+ .map(element => {
+ const name = getTagValue(element, 'D:displayname', '');
+ const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
+ const url = getTagValue(element, 'D:href', '');
+ const nameSuffix = `/${name || ''}`;
+ const directory = url
+ .replace(collectionUrlPrefix, '')
+ .replace(nameSuffix, '');
+
+ const data = {
+ url,
+ id: `${directory}/${name}`,
+ name,
+ path: directory,
+ };
+
+ return getTagValue(element, 'D:resourcetype', '')
+ ? createCollectionDirectory(data)
+ : createCollectionFile({ ...data, size });
+
+ });
+};
//
// SPDX-License-Identifier: AGPL-3.0
-import * as _ from "lodash";
import { CommonResourceService } from "~/common/api/common-resource-service";
import { CollectionResource } from "~/models/collection";
-import axios, { AxiosInstance } from "axios";
-import { KeepService } from "../keep-service/keep-service";
+import { AxiosInstance } from "axios";
+import { CollectionFile, CollectionDirectory } from "~/models/collection-file";
import { WebDAV } from "~/common/webdav";
import { AuthService } from "../auth-service/auth-service";
-import { mapTree, getNodeChildren, getNode, TreeNode } from "../../models/tree";
-import { getTagValue } from "~/common/xml";
-import { FilterBuilder } from "~/common/api/filter-builder";
-import { CollectionFile, createCollectionFile, CollectionFileType, CollectionDirectory, createCollectionDirectory } from '~/models/collection-file';
-import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
-import { KeepManifestStream } from "~/models/keep-manifest";
-import { createCollectionFilesTree } from '~/models/collection-file';
+import { mapTreeValues } from "~/models/tree";
+import { parseFilesResponse } from "./collection-service-files-response";
+import { fileToArrayBuffer } from "~/common/file";
export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
export class CollectionService extends CommonResourceService<CollectionResource> {
- constructor(serverApi: AxiosInstance, private keepService: KeepService, private webdavClient: WebDAV, private authService: AuthService) {
+ constructor(serverApi: AxiosInstance, 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) {
- const files = this.extractFilesData(request.responseXML);
- const tree = createCollectionFilesTree(files);
- const sortedTree = mapTree(node => {
- const children = getNodeChildren(node.id)(tree).map(id => getNode(id)(tree)) as TreeNode<CollectionDirectory | CollectionFile>[];
- children.sort((a, b) =>
- a.value.type !== b.value.type
- ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
- : a.value.name.localeCompare(b.value.name)
- );
- return { ...node, children: children.map(child => child.id) };
- })(tree);
- return sortedTree;
+ const filesTree = parseFilesResponse(request.responseXML);
+ return mapTreeValues(this.extendFileURL)(filesTree);
}
return Promise.reject();
}
- async deleteFile(collectionUuid: string, filePath: string) {
- return this.webdavClient.delete(`/c=${collectionUuid}${filePath}`);
- }
-
- extractFilesData(document: Document) {
- const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
- return Array
- .from(document.getElementsByTagName('D:response'))
- .slice(1) // omit first element which is collection itself
- .map(element => {
- const name = getTagValue(element, 'D:displayname', '');
- const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
- const pathname = getTagValue(element, 'D:href', '');
- const nameSuffix = `/${name || ''}`;
- const directory = pathname
- .replace(collectionUrlPrefix, '')
- .replace(nameSuffix, '');
- const href = this.webdavClient.defaults.baseURL + pathname + '?api_token=' + this.authService.getApiToken();
-
- const data = {
- url: href,
- id: `${directory}/${name}`,
- name,
- path: directory,
- };
-
- return getTagValue(element, 'D:resourcetype', '')
- ? createCollectionDirectory(data)
- : createCollectionFile({ ...data, size });
-
- });
+ async deleteFiles(collectionUuid: string, filePaths: string[]) {
+ for (const path of filePaths) {
+ await this.webdavClient.delete(`/c=${collectionUuid}${path}`);
+ }
}
-
- private readFile(file: File): Promise<ArrayBuffer> {
- return new Promise<ArrayBuffer>(resolve => {
- const reader = new FileReader();
- reader.onload = () => {
- resolve(reader.result as ArrayBuffer);
- };
-
- reader.readAsArrayBuffer(file);
- });
+ async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress) {
+ // files have to be uploaded sequentially
+ for (let idx = 0; idx < files.length; idx++) {
+ await this.uploadFile(collectionUuid, files[idx], idx, onProgress);
+ }
}
- private uploadFile(keepServiceHost: string, file: File, fileId: number, onProgress?: UploadProgress): Promise<CollectionFile> {
- return this.readFile(file).then(content => {
- return axios.post<string>(keepServiceHost, content, {
- headers: {
- 'Content-Type': 'text/octet-stream'
- },
- onUploadProgress: (e: ProgressEvent) => {
- if (onProgress) {
- onProgress(fileId, e.loaded, e.total, Date.now());
- }
- console.log(`${e.loaded} / ${e.total}`);
- }
- }).then(data => createCollectionFile({
- id: data.data,
- name: file.name,
- size: file.size
- }));
- });
+ moveFile(collectionUuid: string, oldPath: string, newPath: string) {
+ return this.webdavClient.move(
+ `/c=${collectionUuid}${oldPath}`,
+ `/c=${collectionUuid}${encodeURI(newPath)}`
+ );
}
- private async updateManifest(collectionUuid: string, files: CollectionFile[]): Promise<CollectionResource> {
- const collection = await this.get(collectionUuid);
- const manifest: KeepManifestStream[] = parseKeepManifestText(collection.manifestText);
-
- files.forEach(f => {
- let kms = manifest.find(stream => stream.name === f.path);
- if (!kms) {
- kms = {
- files: [],
- locators: [],
- name: f.path
- };
- manifest.push(kms);
+ private extendFileURL = (file: CollectionDirectory | CollectionFile) => ({
+ ...file,
+ url: this.webdavClient.defaults.baseURL + file.url + '?api_token=' + this.authService.getApiToken()
+ })
+
+ private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
+ const fileURL = `/c=${collectionUuid}/${file.name}`;
+ const fileContent = await fileToArrayBuffer(file);
+ const requestConfig = {
+ headers: {
+ 'Content-Type': 'text/octet-stream'
+ },
+ onUploadProgress: (e: ProgressEvent) => {
+ onProgress(fileId, e.loaded, e.total, Date.now());
}
- kms.locators.push(f.id);
- const len = kms.files.length;
- const nextPos = len > 0
- ? parseInt(kms.files[len - 1].position, 10) + kms.files[len - 1].size
- : 0;
- kms.files.push({
- name: f.name,
- position: nextPos.toString(),
- size: f.size
- });
- });
+ };
+ return this.webdavClient.put(fileURL, fileContent, requestConfig);
- console.log(manifest);
-
- const manifestText = stringifyKeepManifest(manifest);
- const data = { ...collection, manifestText };
- return this.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
}
- uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress): Promise<CollectionResource | never> {
- const filters = new FilterBuilder()
- .addEqual("service_type", "proxy");
-
- return this.keepService.list({ filters: filters.getFilters() }).then(data => {
- if (data.items && data.items.length > 0) {
- const serviceHost =
- (data.items[0].serviceSslFlag ? "https://" : "http://") +
- data.items[0].serviceHost +
- ":" + data.items[0].servicePort;
-
- console.log("serviceHost", serviceHost);
-
- const files$ = files.map((f, idx) => this.uploadFile(serviceHost, f, idx, onProgress));
- return Promise.all(files$).then(values => {
- return this.updateManifest(collectionUuid, values);
- });
- } else {
- return Promise.reject("Missing keep service host");
- }
- });
- }
}
const projectService = new ProjectService(apiClient);
const linkService = new LinkService(apiClient);
const favoriteService = new FavoriteService(linkService, groupsService);
- const collectionService = new CollectionService(apiClient, keepService, webdavClient, authService);
+ const collectionService = new CollectionService(apiClient, webdavClient, authService);
const tagService = new TagService(linkService);
const collectionFilesService = new CollectionFilesService(collectionService);
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";
+import { dialogActions } from '../../dialog/dialog-actions';
+import { getNodeValue } from "~/models/tree";
+import { filterCollectionFilesBySelection } from './collection-panel-files-state';
+import { startSubmit, initialize, stopSubmit, reset } from 'redux-form';
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
+import { getDialog } from "~/store/dialog/dialog-reducer";
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
export const collectionPanelFilesAction = unionize({
SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
export const removeCollectionFiles = (filePaths: string[]) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- const { item } = getState().collectionPanel;
- if (item) {
+ const currentCollection = getState().collectionPanel.item;
+ if (currentCollection) {
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));
+ await services.collectionService.deleteFiles(currentCollection.uuid, filePaths);
+ dispatch<any>(loadCollectionFiles(currentCollection.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);
+ const paths = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).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);
};
export const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
+
export const openMultipleFilesRemoveDialog = () =>
dialogActions.OPEN_DIALOG({
id: MULTIPLE_FILES_REMOVE_DIALOG,
confirmButtonLabel: 'Remove'
}
});
+
+export const COLLECTION_PARTIAL_COPY = 'COLLECTION_PARTIAL_COPY';
+
+export interface CollectionPartialCopyFormData {
+ name: string;
+ description: string;
+ projectUuid: string;
+}
+
+export const openCollectionPartialCopyDialog = () =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const currentCollection = getState().collectionPanel.item;
+ if (currentCollection) {
+ const initialData = {
+ name: currentCollection.name,
+ description: currentCollection.description,
+ projectUuid: ''
+ };
+ dispatch(initialize(COLLECTION_PARTIAL_COPY, initialData));
+ dispatch<any>(resetPickerProjectTree());
+ dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY, data: {} }));
+ }
+ };
+
+export const doCollectionPartialCopy = ({ name, description, projectUuid }: CollectionPartialCopyFormData) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(startSubmit(COLLECTION_PARTIAL_COPY));
+ const state = getState();
+ const currentCollection = state.collectionPanel.item;
+ if (currentCollection) {
+ try {
+ const collection = await services.collectionService.get(currentCollection.uuid);
+ const collectionCopy = {
+ ...collection,
+ name,
+ description,
+ ownerUuid: projectUuid,
+ uuid: undefined
+ };
+ const newCollection = await services.collectionService.create(collectionCopy);
+ const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
+ await services.collectionService.deleteFiles(newCollection.uuid, paths);
+ dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'New collection created.', hideDuration: 2000 }));
+ } catch (e) {
+ const error = getCommonResourceServiceError(e);
+ if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+ dispatch(stopSubmit(COLLECTION_PARTIAL_COPY, { name: 'Collection with this name already exists.' }));
+ } else if (error === CommonResourceServiceError.UNKNOWN) {
+ dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000 }));
+ } else {
+ dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000 }));
+ }
+ }
+ }
+ };
+
+export const RENAME_FILE_DIALOG = 'renameFileDialog';
+export interface RenameFileDialogData {
+ name: string;
+ id: string;
+}
+
+export const openRenameFileDialog = (data: RenameFileDialogData) =>
+ (dispatch: Dispatch) => {
+ dispatch(reset(RENAME_FILE_DIALOG));
+ dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data }));
+ };
+
+export const renameFile = (newName: string) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const dialog = getDialog<RenameFileDialogData>(getState().dialog, RENAME_FILE_DIALOG);
+ const currentCollection = getState().collectionPanel.item;
+ if (dialog && currentCollection) {
+ dispatch(startSubmit(RENAME_FILE_DIALOG));
+ const oldPath = dialog.data.id;
+ const newPath = dialog.data.id.replace(dialog.data.name, newName);
+ try {
+ await services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath);
+ dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+ dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
+ } catch (e) {
+ dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Could not rename the file' }));
+ }
+ }
+ };
//
// SPDX-License-Identifier: AGPL-3.0
-import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from "./collection-panel-files-state";
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from './collection-panel-files-state';
import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
-import { createTree, mapTreeValues, getNode, setNode, getNodeAncestors, getNodeDescendants, setNodeValueWith, mapTree } from "~/models/tree";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "~/models/tree";
import { CollectionFileType } from "~/models/collection-file";
export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
const node = getNode(id)(tree);
if (node && node.value.type === CollectionFileType.DIRECTORY) {
- return getNodeDescendants(id)(tree)
+ return getNodeDescendantsIds(id)(tree)
.reduce((newTree, id) =>
setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
}
};
const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
- const ancestors = getNodeAncestors(id)(tree).reverse();
+ const ancestors = getNodeAncestorsIds(id)(tree).reverse();
return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
};
//
// SPDX-License-Identifier: AGPL-3.0
-import { Tree, TreeNode, mapTreeValues, getNodeValue } from '~/models/tree';
+import { Tree, TreeNode, mapTreeValues, getNodeValue, getNodeDescendants } from '~/models/tree';
import { CollectionFile, CollectionDirectory, CollectionFileType } from '~/models/collection-file';
export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
: { ...value, selected: oldValue.selected }
: value;
})(newState);
-};
+};
+
+export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean) => {
+ const allFiles = getNodeDescendants('')(tree).map(node => node.value);
+
+ const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
+ const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
+ return [...selectedDirectories, ...selectedFiles];
+};
import { RootState } from "../../store";
import { CollectionResource } from '~/models/collection';
import { ServiceRepository } from "~/services/services";
-import { collectionUploaderActions } from "../uploader/collection-uploader-actions";
+import { uploadCollectionFiles } from '../uploader/collection-uploader-actions';
import { reset } from "redux-form";
export const collectionCreateActions = unionize({
CREATE_COLLECTION: ofType<{}>(),
CREATE_COLLECTION_SUCCESS: ofType<{}>(),
}, {
- tag: 'type',
- value: 'payload'
-});
+ tag: 'type',
+ value: 'payload'
+ });
+
+export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
export const createCollection = (collection: Partial<CollectionResource>, files: File[]) =>
- (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const { ownerUuid } = getState().collections.creator;
const collectiontData = { ownerUuid, ...collection };
dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
- return services.collectionService
- .create(collectiontData)
- .then(collection => {
- dispatch(collectionUploaderActions.START_UPLOAD());
- services.collectionService.uploadFiles(collection.uuid, files,
- (fileId, loaded, total, currentTime) => {
- dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
- })
- .then(collection => {
- dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
- dispatch(reset('collectionCreateDialog'));
- dispatch(collectionUploaderActions.CLEAR_UPLOAD());
- });
- return collection;
- });
+ const newCollection = await services.collectionService.create(collectiontData);
+ await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
+ dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
+ dispatch(reset('collectionCreateDialog'));
+ return newCollection;
};
-export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
// SPDX-License-Identifier: AGPL-3.0\r
\r
import { default as unionize, ofType, UnionOf } from "unionize";\r
+import { Dispatch } from 'redux';\r
+import { RootState } from '~/store/store';\r
+import { ServiceRepository } from '~/services/services';\r
+import { dialogActions } from '~/store/dialog/dialog-actions';\r
+import { loadCollectionFiles } from '../../collection-panel/collection-panel-files/collection-panel-files-actions';\r
+import { snackbarActions } from "~/store/snackbar/snackbar-actions";\r
\r
export interface UploadFile {\r
id: number;\r
});\r
\r
export type CollectionUploaderAction = UnionOf<typeof collectionUploaderActions>;\r
+\r
+export const uploadCollectionFiles = (collectionUuid: string) =>\r
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+ dispatch(collectionUploaderActions.START_UPLOAD());\r
+ const files = getState().collections.uploader.map(file => file.file);\r
+ await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch));\r
+ dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+ };\r
+\r
+\r
+export const uploadCurrentCollectionFiles = () =>\r
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+ const currentCollection = getState().collectionPanel.item;\r
+ if (currentCollection) {\r
+ await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));\r
+ dispatch<any>(loadCollectionFiles(currentCollection.uuid));\r
+ dispatch(closeUploadCollectionFilesDialog());\r
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Data has been uploaded.', hideDuration: 2000 }));\r
+ }\r
+ };\r
+\r
+export const UPLOAD_COLLECTION_FILES_DIALOG = 'uploadCollectionFilesDialog';\r
+export const openUploadCollectionFilesDialog = () => (dispatch: Dispatch) => {\r
+ dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+ dispatch<any>(dialogActions.OPEN_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG, data: {} }));\r
+};\r
+\r
+export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG });\r
+\r
+const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {\r
+ dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));\r
+};
\ No newline at end of file
filters: [],
render: jest.fn(),
selected: true,
- configurable: true,
- sortDirection: SortDirection.ASC
+ sortDirection: SortDirection.ASC,
+ configurable: true
}, {
name: "Column 2",
filters: [],
import { DialogAction, dialogActions } from "./dialog-actions";
-export type DialogState = Record<string, Dialog>;
+export type DialogState = Record<string, Dialog<any>>;
-export interface Dialog {
+export interface Dialog <T> {
open: boolean;
- data: any;
+ data: T;
}
export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
default: () => state,
});
+export const getDialog = <T>(state: DialogState, id: string) =>
+ state[id] ? state[id] as Dialog<T> : undefined;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { TreePickerId, receiveTreePickerData } from "~/views-components/project-tree-picker/project-tree-picker";
+import { mockProjectResource } from "~/models/test-utils";
+import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
+
+export const resetPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.PROJECTS}));
+ dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.SHARED_WITH_ME}));
+ dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.FAVORITES}));
+
+ dispatch<any>(initPickerProjectTree());
+};
+
+export const initPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const uuid = services.authService.getUuid();
+
+ dispatch<any>(getPickerTreeProjects(uuid));
+ dispatch<any>(getSharedWithMeProjectsPickerTree(uuid));
+ dispatch<any>(getFavoritesProjectsPickerTree(uuid));
+};
+
+const getPickerTreeProjects = (uuid: string = '') => {
+ return getProjectsPickerTree(uuid, TreePickerId.PROJECTS);
+};
+
+const getSharedWithMeProjectsPickerTree = (uuid: string = '') => {
+ return getProjectsPickerTree(uuid, TreePickerId.SHARED_WITH_ME);
+};
+
+const getFavoritesProjectsPickerTree = (uuid: string = '') => {
+ return getProjectsPickerTree(uuid, TreePickerId.FAVORITES);
+};
+
+const getProjectsPickerTree = (uuid: string, kind: string) => {
+ return receiveTreePickerData(
+ '',
+ [mockProjectResource({ uuid, name: kind })],
+ kind
+ );
+};
\ No newline at end of file
id: "1",
items: [],
data: mockProjectResource({ uuid: "1" }),
- status: 0
+ status: TreeItemStatus.INITIAL
}, {
active: false,
open: false,
// SPDX-License-Identifier: AGPL-3.0
import { default as unionize, ofType, UnionOf } from "unionize";
+
import { TreePickerNode } from "./tree-picker";
export const treePickerActions = unionize({
- LOAD_TREE_PICKER_NODE: ofType<{ id: string }>(),
- LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreePickerNode> }>(),
- TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string }>(),
- TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ id: string }>()
+ LOAD_TREE_PICKER_NODE: ofType<{ nodeId: string, pickerId: string }>(),
+ LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ nodeId: string, nodes: Array<TreePickerNode>, pickerId: string }>(),
+ TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ nodeId: string, pickerId: string }>(),
+ TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ nodeId: string, pickerId: string }>(),
+ RESET_TREE_PICKER: ofType<{ pickerId: string }>()
}, {
tag: 'type',
value: 'payload'
//
// SPDX-License-Identifier: AGPL-3.0
-import { createTree, getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTree, getNodeValue, getNodeChildrenIds } from "~/models/tree";
import { TreePickerNode, createTreePickerNode } from "./tree-picker";
import { treePickerReducer } from "./tree-picker-reducer";
import { treePickerActions } from "./tree-picker-actions";
describe('TreePickerReducer', () => {
it('LOAD_TREE_PICKER_NODE - initial state', () => {
const tree = createTree<TreePickerNode>();
- const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' }));
- expect(newTree).toEqual(tree);
+ const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" }));
+ expect(newState).toEqual({ 'projects': tree });
});
it('LOAD_TREE_PICKER_NODE', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' })));
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" })));
+
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
status: TreeItemStatus.PENDING
});
});
it('LOAD_TREE_PICKER_NODE_SUCCESS - initial state', () => {
- const tree = createTree<TreePickerNode>();
- const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
- const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode] }));
- expect(getNodeChildren('')(newTree)).toEqual(['1.1']);
+ const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+ const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [subNode], pickerId: "projects" }));
+ expect(getNodeChildrenIds('')(newState.projects)).toEqual(['1.1']);
});
it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode] })));
- expect(getNodeChildren('1')(newTree)).toEqual(['1.1']);
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '1', nodes: [subNode], pickerId: "projects" })));
+ expect(getNodeChildrenIds('1')(newState.projects)).toEqual(['1.1']);
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
status: TreeItemStatus.LOADED
});
});
it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - collapsed', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
collapsed: false
});
});
it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
collapsed: true
});
});
it('TOGGLE_TREE_PICKER_NODE_SELECT - selected', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
selected: true
});
});
it('TOGGLE_TREE_PICKER_NODE_SELECT - not selected', () => {
- const tree = createTree<TreePickerNode>();
- const node = createTreePickerNode({ id: '1', value: '1' });
- const [newTree] = [tree]
- .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })))
- .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
- expect(getNodeValue('1')(newTree)).toEqual({
- ...createTreePickerNode({ id: '1', value: '1' }),
+ const node = createTreePickerNode({ nodeId: '1', value: '1' });
+ const [newState] = [{
+ projects: createTree<TreePickerNode>()
+ }]
+ .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })))
+ .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+ expect(getNodeValue('1')(newState.projects)).toEqual({
+ ...createTreePickerNode({ nodeId: '1', value: '1' }),
selected: false
});
});
//
// SPDX-License-Identifier: AGPL-3.0
-import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues } from "~/models/tree";
+import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues, Tree } from "~/models/tree";
import { TreePicker, TreePickerNode } from "./tree-picker";
import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
import { TreeItemStatus } from "~/components/tree/tree";
+import { compose } from "redux";
-export const treePickerReducer = (state: TreePicker = createTree(), action: TreePickerAction) =>
+export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
treePickerActions.match(action, {
- LOAD_TREE_PICKER_NODE: ({ id }) =>
- setNodeValueWith(setPending)(id)(state),
- LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes }) => {
- const [newState] = [state]
- .map(receiveNodes(nodes)(id))
- .map(setNodeValueWith(setLoaded)(id));
- return newState;
- },
- TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id }) =>
- setNodeValueWith(toggleCollapse)(id)(state),
- TOGGLE_TREE_PICKER_NODE_SELECT: ({ id }) =>
- mapTreeValues(toggleSelect(id))(state),
+ LOAD_TREE_PICKER_NODE: ({ nodeId, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, setNodeValueWith(setPending)(nodeId)),
+ LOAD_TREE_PICKER_NODE_SUCCESS: ({ nodeId, nodes, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(nodeId), setNodeValueWith(setLoaded)(nodeId))),
+ TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ nodeId, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, setNodeValueWith(toggleCollapse)(nodeId)),
+ TOGGLE_TREE_PICKER_NODE_SELECT: ({ nodeId, pickerId }) =>
+ updateOrCreatePicker(state, pickerId, mapTreeValues(toggleSelect(nodeId))),
+ RESET_TREE_PICKER: ({ pickerId }) =>
+ updateOrCreatePicker(state, pickerId, createTree),
default: () => state
});
+const updateOrCreatePicker = (state: TreePicker, pickerId: string, func: (value: Tree<TreePickerNode>) => Tree<TreePickerNode>) => {
+ const picker = state[pickerId] || createTree();
+ const updatedPicker = func(picker);
+ return { ...state, [pickerId]: updatedPicker };
+};
+
const setPending = (value: TreePickerNode): TreePickerNode =>
({ ...value, status: TreeItemStatus.PENDING });
const toggleCollapse = (value: TreePickerNode): TreePickerNode =>
({ ...value, collapsed: !value.collapsed });
-const toggleSelect = (id: string) => (value: TreePickerNode): TreePickerNode =>
- value.id === id
+const toggleSelect = (nodeId: string) => (value: TreePickerNode): TreePickerNode =>
+ value.nodeId === nodeId
? ({ ...value, selected: !value.selected })
: ({ ...value, selected: false });
-const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: TreePicker) =>
+const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: Tree<TreePickerNode>) =>
nodes.reduce((tree, node) =>
setNode(
createTreeNode(parent)(node)
const createTreeNode = (parent: string) => (node: TreePickerNode): TreeNode<TreePickerNode> => ({
children: [],
- id: node.id,
+ id: node.nodeId,
parent,
value: node
});
import { Tree } from "~/models/tree";
import { TreeItemStatus } from "~/components/tree/tree";
-export type TreePicker = Tree<TreePickerNode>;
+export type TreePicker = { [key: string]: Tree<TreePickerNode> };
export interface TreePickerNode {
- id: string;
+ nodeId: string;
value: any;
selected: boolean;
collapsed: boolean;
status: TreeItemStatus;
}
-export const createTreePickerNode = (data: {id: string, value: any}) => ({
+export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({
...data,
selected: false,
collapsed: true,
export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
-export const COLLECTION_PROJECT_VALIDATION = [require];
\ No newline at end of file
+export const COLLECTION_PROJECT_VALIDATION = [require];
+
+export const COPY_NAME_VALIDATION = [require, maxLength(255)];
+export const MAKE_A_COPY_VALIDATION = [require, maxLength(255)];
+
+export const MOVE_TO_VALIDATION = [require];
import { collectionPanelFilesAction } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
import { ContextMenuKind } from "../context-menu/context-menu";
-import { Tree, getNodeChildren, getNode } from "~/models/tree";
+import { Tree, getNodeChildrenIds, getNode } from "~/models/tree";
import { CollectionFileType } from "~/models/collection-file";
+import { openUploadCollectionFilesDialog } from '~/store/collections/uploader/collection-uploader-actions';
const memoizedMapStateToProps = () => {
let prevState: CollectionPanelFilesState;
return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
if (prevState !== state.collectionPanelFiles) {
prevState = state.collectionPanelFiles;
- prevTree = getNodeChildren('')(state.collectionPanelFiles)
+ prevTree = getNodeChildrenIds('')(state.collectionPanelFiles)
.map(collectionItemToTreeItem(state.collectionPanelFiles));
}
return {
};
const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
- onUploadDataClick: () => { return; },
+ onUploadDataClick: () => {
+ dispatch<any>(openUploadCollectionFilesDialog());
+ },
onCollapseToggle: (id) => {
dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
},
type: node.value.type
},
id: node.id,
- items: getNodeChildren(node.id)(tree)
+ items: getNodeChildrenIds(node.id)(tree)
.map(collectionItemToTreeItem(tree)),
open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
selected: node.value.selected,
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { compose } from "redux";
+import { reduxForm, InjectedFormProps } from 'redux-form';
+import { withDialog, WithDialogProps } from '~/store/dialog/with-dialog';
+import { CollectionPartialCopyFields } from '../form-dialog/collection-form-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { COLLECTION_PARTIAL_COPY, doCollectionPartialCopy, CollectionPartialCopyFormData } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
+
+export const CollectionPartialCopyDialog = compose(
+ withDialog(COLLECTION_PARTIAL_COPY),
+ reduxForm({
+ form: COLLECTION_PARTIAL_COPY,
+ onSubmit: (data: CollectionPartialCopyFormData, dispatch) => {
+ dispatch(doCollectionPartialCopy(data));
+ }
+ }))((props: WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyFormData>) =>
+ <FormDialog
+ dialogTitle='Create a collection'
+ formFields={CollectionPartialCopyFields}
+ submitLabel='Create a collection'
+ {...props}
+ />);
import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RemoveIcon } from "~/components/icon/icon";
import { openUpdater } from "~/store/collections/updater/collection-updater-action";
import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
+import { openMoveToDialog } from "../../move-to-dialog/move-to-dialog";
export const collectionActionSet: ContextMenuActionSet = [[
{
{
icon: MoveToIcon,
name: "Move to",
- execute: (dispatch, resource) => {
- // add code
- }
+ execute: dispatch => dispatch<any>(openMoveToDialog())
},
{
component: ToggleFavoriteAction,
icon: CopyIcon,
name: "Copy to project",
execute: (dispatch, resource) => {
- // add code
+ dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
}
},
{
import { ContextMenuActionSet } from "../context-menu-action-set";
import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
-import { createCollectionWithSelected } from "~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected";
-
+import { openCollectionPartialCopyDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
export const collectionFilesActionSet: ContextMenuActionSet = [[{
name: "Select all",
}, {
name: "Create a new collection with selected",
execute: (dispatch) => {
- dispatch<any>(createCollectionWithSelected());
+ dispatch<any>(openCollectionPartialCopyDialog());
}
}]];
// SPDX-License-Identifier: AGPL-3.0
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 { RenameIcon, RemoveIcon } from "~/components/icon/icon";
import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
name: "Rename",
icon: RenameIcon,
execute: (dispatch, resource) => {
- dispatch<any>(openRenameFileDialog(resource.name));
+ dispatch<any>(openRenameFileDialog({ name: resource.name, id: resource.uuid }));
}
}, {
component: DownloadCollectionFileAction,
import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, RemoveIcon } from "~/components/icon/icon";
import { openUpdater } from "~/store/collections/updater/collection-updater-action";
import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
+import { openMoveToDialog } from "~/views-components/move-to-dialog/move-to-dialog";
export const collectionResourceActionSet: ContextMenuActionSet = [[
{
{
icon: MoveToIcon,
name: "Move to",
- execute: (dispatch, resource) => {
- // add code
- }
+ execute: dispatch => dispatch<any>(openMoveToDialog())
},
{
component: ToggleFavoriteAction,
icon: CopyIcon,
name: "Copy to project",
execute: (dispatch, resource) => {
- // add code
- }
+ dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
+ },
},
{
icon: DetailsIcon,
import { ContextMenuActionSet } from "../context-menu-action-set";
import { projectActions, PROJECT_FORM_NAME } from "~/store/project/project-action";
-import { NewProjectIcon, RenameIcon } from "~/components/icon/icon";
+import { NewProjectIcon, RenameIcon, CopyIcon, MoveToIcon } from "~/components/icon/icon";
import { ToggleFavoriteAction } from "../actions/favorite-action";
import { toggleFavorite } from "~/store/favorites/favorites-actions";
import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openMoveToDialog } from "../../move-to-dialog/move-to-dialog";
import { PROJECT_CREATE_DIALOG } from "../../dialog-create/dialog-project-create";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
export const projectActionSet: ContextMenuActionSet = [[
{
dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
});
}
+ },
+ {
+ icon: MoveToIcon,
+ name: "Move to",
+ execute: dispatch => dispatch<any>(openMoveToDialog())
+ },
+ {
+ icon: CopyIcon,
+ name: "Copy to project",
+ execute: (dispatch, resource) => {
+ dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
+ }
}
]];
import { withDialog } from "~/store/dialog/with-dialog";
import { dialogActions } from "~/store/dialog/dialog-actions";
import { DialogCollectionCreateWithSelected } from "../dialog-create/dialog-collection-create-selected";
-import { loadProjectTreePickerProjects } from "../project-tree-picker/project-tree-picker";
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
export const DIALOG_COLLECTION_CREATE_WITH_SELECTED = 'dialogCollectionCreateWithSelected';
export const createCollectionWithSelected = () =>
(dispatch: Dispatch) => {
dispatch(reset(DIALOG_COLLECTION_CREATE_WITH_SELECTED));
- dispatch<any>(loadProjectTreePickerProjects(''));
+ dispatch<any>(resetPickerProjectTree());
dispatch(dialogActions.OPEN_DIALOG({ id: DIALOG_COLLECTION_CREATE_WITH_SELECTED, data: {} }));
};
return <div>
<DetailsAttribute label='Type' value={resourceLabel(ResourceKind.COLLECTION)} />
<DetailsAttribute label='Size' value='---' />
- <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+ <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
<DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
<DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
{/* Links but we dont have view */}
import { DetailsData } from "./details-data";
import { DetailsResource } from "~/models/details";
-type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
+type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'headerTitle' | 'tabContainer';
const drawerWidth = 320;
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
textAlign: 'center'
},
headerIcon: {
- fontSize: "34px"
+ fontSize: '2.125rem'
+ },
+ headerTitle: {
+ wordBreak: 'break-all'
},
tabContainer: {
padding: theme.spacing.unit * 3
{item.getIcon(classes.headerIcon)}
</Grid>
<Grid item xs={8}>
- <Typography variant="title">
+ <Typography variant="title" className={classes.headerTitle}>
{item.getTitle()}
</Typography>
</Grid>
return <div>
<DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
<DetailsAttribute label='Size' value='---' />
- <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+ <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
{/* Missing attr */}
<DetailsAttribute label='Status' value={this.item.state} />
<DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
{/* Missing attr */}
<DetailsAttribute label='Size' value='---' />
- <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+ <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
<DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
<DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
{/* Missing attr */}
type='submit'
onClick={props.handleSubmit}
disabled={props.pristine || props.invalid || props.submitting}>
- {props.submitting
- ? <CircularProgress size={20} />
- : 'Create a collection'}
+ {props.submitting ? <CircularProgress size={20} /> : 'Create a collection'}
</Button>
</DialogActions>
</Dialog>
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field, WrappedFieldProps } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
+import { ProjectTreePicker } from "../project-tree-picker/project-tree-picker";
+
+export const CollectionPartialCopyFields = () => <div style={{ display: 'flex' }}>
+ <div>
+ <CollectionNameField />
+ <CollectionDescriptionField />
+ </div>
+ <CollectionProjectPickerField />
+</div>;
+
+export const CollectionNameField = () =>
+ <Field
+ name='name'
+ component={TextField}
+ validate={COLLECTION_NAME_VALIDATION}
+ label="Collection Name" />;
+
+export const CollectionDescriptionField = () =>
+ <Field
+ name='description'
+ component={TextField}
+ validate={COLLECTION_DESCRIPTION_VALIDATION}
+ label="Description - optional" />;
+
+export const CollectionProjectPickerField = () =>
+ <Field
+ name="projectUuid"
+ component={ProjectPicker}
+ validate={COLLECTION_PROJECT_VALIDATION} />;
+
+const ProjectPicker = (props: WrappedFieldProps) =>
+ <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+ <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+ </div>;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from "redux";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+import { MoveToDialog } from "../../components/move-to-dialog/move-to-dialog";
+import { reduxForm, startSubmit, stopSubmit } from "redux-form";
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
+
+export const MOVE_TO_DIALOG = 'moveToDialog';
+
+export const openMoveToDialog = () =>
+ (dispatch: Dispatch) => {
+ dispatch<any>(resetPickerProjectTree());
+ dispatch(dialogActions.OPEN_DIALOG({ id: MOVE_TO_DIALOG, data: {} }));
+ };
+
+export const MoveToProjectDialog = compose(
+ withDialog(MOVE_TO_DIALOG),
+ reduxForm({
+ form: MOVE_TO_DIALOG,
+ onSubmit: (data, dispatch) => {
+ dispatch(startSubmit(MOVE_TO_DIALOG));
+ setTimeout(() => dispatch(stopSubmit(MOVE_TO_DIALOG, { name: 'Invalid path' })), 2000);
+ }
+ })
+)(MoveToDialog);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from "redux";
+import { withDialog } from "~/store/dialog/with-dialog";
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { ProjectCopy, CopyFormData } from "~/components/project-copy/project-copy";
+import { reduxForm, startSubmit, stopSubmit, initialize } from 'redux-form';
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
+
+export const PROJECT_COPY_DIALOG = 'projectCopy';
+export const openProjectCopyDialog = (data: { projectUuid: string, name: string }) =>
+ (dispatch: Dispatch) => {
+ dispatch<any>(resetPickerProjectTree());
+ const initialData: CopyFormData = { name: `Copy of: ${data.name}`, projectUuid: '', uuid: data.projectUuid };
+ dispatch<any>(initialize(PROJECT_COPY_DIALOG, initialData));
+ dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_COPY_DIALOG, data: {} }));
+ };
+
+export const ProjectCopyDialog = compose(
+ withDialog(PROJECT_COPY_DIALOG),
+ reduxForm({
+ form: PROJECT_COPY_DIALOG,
+ onSubmit: (data, dispatch) => {
+ dispatch(startSubmit(PROJECT_COPY_DIALOG));
+ setTimeout(() => dispatch(stopSubmit(PROJECT_COPY_DIALOG, { name: 'Invalid path' })), 2000);
+ }
+ })
+)(ProjectCopy);
\ No newline at end of file
import { Dispatch } from "redux";
import { connect } from "react-redux";
import { Typography } from "@material-ui/core";
-import { TreePicker } from "../tree-picker/tree-picker";
-import { TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
+import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
+import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
import { ProjectResource } from "~/models/project";
import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon } from "~/components/icon/icon";
+import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon } from "~/components/icon/icon";
import { createTreePickerNode } from "~/store/tree-picker/tree-picker";
import { RootState } from "~/store/store";
import { ServiceRepository } from "~/services/services";
import { FilterBuilder } from "~/common/api/filter-builder";
-type ProjectTreePickerProps = Pick<TreeProps<ProjectResource>, 'toggleItemActive' | 'toggleItemOpen'>;
+type ProjectTreePickerProps = Pick<TreePickerProps, 'toggleItemActive' | 'toggleItemOpen'>;
-const mapDispatchToProps = (dispatch: Dispatch, props: {onChange: (projectUuid: string) => void}): ProjectTreePickerProps => ({
- toggleItemActive: id => {
- dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id }));
- props.onChange(id);
+const mapDispatchToProps = (dispatch: Dispatch, props: { onChange: (projectUuid: string) => void }): ProjectTreePickerProps => ({
+ toggleItemActive: (nodeId, status, pickerId) => {
+ getNotSelectedTreePickerKind(pickerId)
+ .forEach(pickerId => dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '', pickerId })));
+ dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId }));
+
+ props.onChange(nodeId);
},
- toggleItemOpen: (id, status) => {
- status === TreeItemStatus.INITIAL
- ? dispatch<any>(loadProjectTreePickerProjects(id))
- : dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+ toggleItemOpen: (nodeId, status, pickerId) => {
+ dispatch<any>(toggleItemOpen(nodeId, status, pickerId));
}
});
+const toggleItemOpen = (nodeId: string, status: TreeItemStatus, pickerId: string) =>
+ (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ if (status === TreeItemStatus.INITIAL) {
+ if (pickerId === TreePickerId.PROJECTS) {
+ dispatch<any>(loadProjectTreePickerProjects(nodeId));
+ } else if (pickerId === TreePickerId.FAVORITES) {
+ dispatch<any>(loadFavoriteTreePickerProjects(nodeId === services.authService.getUuid() ? '' : nodeId));
+ } else {
+ // TODO: load sharedWithMe
+ }
+ } else {
+ dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
+ }
+ };
+
+const getNotSelectedTreePickerKind = (pickerId: string) => {
+ return [TreePickerId.PROJECTS, TreePickerId.FAVORITES, TreePickerId.SHARED_WITH_ME].filter(nodeId => nodeId !== pickerId);
+};
+
+export enum TreePickerId {
+ PROJECTS = 'Projects',
+ SHARED_WITH_ME = 'Shared with me',
+ FAVORITES = 'Favorites'
+}
+
export const ProjectTreePicker = connect(undefined, mapDispatchToProps)((props: ProjectTreePickerProps) =>
- <div style={{display: 'flex', flexDirection: 'column'}}>
- <Typography variant='caption' style={{flexShrink: 0}}>
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
+ <Typography variant='caption' style={{ flexShrink: 0 }}>
Select a project
</Typography>
- <div style={{flexGrow: 1, overflow: 'auto'}}>
- <TreePicker {...props} render={renderTreeItem} />
+ <div style={{ flexGrow: 1, overflow: 'auto' }}>
+ <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.PROJECTS} />
+ <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.SHARED_WITH_ME} />
+ <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.FAVORITES} />
</div>
</div>);
+
// TODO: move action creator to store directory
-export const loadProjectTreePickerProjects = (id: string) =>
+export const loadProjectTreePickerProjects = (nodeId: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id }));
+ dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.PROJECTS }));
- const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
+ const ownerUuid = nodeId.length === 0 ? services.authService.getUuid() || '' : nodeId;
const filters = new FilterBuilder()
.addEqual('ownerUuid', ownerUuid)
const { items } = await services.projectService.list({ filters });
- dispatch<any>(receiveProjectTreePickerData(id, items));
+ dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.PROJECTS));
+ };
+
+export const loadFavoriteTreePickerProjects = (nodeId: string) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const parentId = services.authService.getUuid() || '';
+
+ if (nodeId === '') {
+ dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: parentId, pickerId: TreePickerId.FAVORITES }));
+ const { items } = await services.favoriteService.list(parentId);
+
+ dispatch<any>(receiveTreePickerData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
+ } else {
+ dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.FAVORITES }));
+ const filters = new FilterBuilder()
+ .addEqual('ownerUuid', nodeId)
+ .getFilters();
+
+ const { items } = await services.projectService.list({ filters });
+
+ dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.FAVORITES));
+ }
+
};
+const getProjectPickerIcon = (item: TreeItem<ProjectResource>) => {
+ switch (item.data.name) {
+ case TreePickerId.FAVORITES:
+ return FavoriteIcon;
+ case TreePickerId.PROJECTS:
+ return ProjectsIcon;
+ case TreePickerId.SHARED_WITH_ME:
+ return ShareMeIcon;
+ default:
+ return ProjectIcon;
+ }
+};
+
const renderTreeItem = (item: TreeItem<ProjectResource>) =>
<ListItemTextIcon
- icon={ProjectIcon}
+ icon={getProjectPickerIcon(item)}
name={item.data.name}
isActive={item.active}
hasMargin={true} />;
+
// TODO: move action creator to store directory
-const receiveProjectTreePickerData = (id: string, projects: ProjectResource[]) =>
+export const receiveTreePickerData = (nodeId: string, projects: ProjectResource[], pickerId: string) =>
(dispatch: Dispatch) => {
dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
- id,
- nodes: projects.map(project => createTreePickerNode({ id: project.uuid, value: project }))
+ nodeId,
+ nodes: projects.map(project => createTreePickerNode({ nodeId: project.uuid, value: project })),
+ pickerId,
}));
- dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+
+ dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
};
+
+
import CircularProgress from '@material-ui/core/CircularProgress';
import { ProjectTree } from './project-tree';
-import { TreeItem } from '~/components/tree/tree';
-import { ProjectResource } from '~/models/project';
-import { mockProjectResource } from '~/models/test-utils';
+import { TreeItem, TreeItemStatus } from '../../components/tree/tree';
+import { ProjectResource } from '../../models/project';
+import { mockProjectResource } from '../../models/test-utils';
Enzyme.configure({ adapter: new Adapter() });
id: "3",
open: true,
active: true,
- status: 1
+ status: TreeItemStatus.PENDING
};
const wrapper = mount(<ProjectTree
projects={[project]}
id: "3",
open: true,
active: true,
- status: 2,
+ status: TreeItemStatus.LOADED,
items: [
{
data: mockProjectResource(),
id: "3",
open: true,
active: true,
- status: 1
+ status: TreeItemStatus.PENDING
}
]
}
id: "3",
open: false,
active: true,
- status: 1
+ status: TreeItemStatus.PENDING
};
const wrapper = mount(<ProjectTree
projects={[project]}
//
// SPDX-License-Identifier: AGPL-3.0
-import { Dispatch } from "redux";
-import { reduxForm, reset, startSubmit, stopSubmit } from "redux-form";
-import { withDialog } from "~/store/dialog/with-dialog";
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { RenameDialog } from "~/components/rename-dialog/rename-dialog";
+import * as React from 'react';
+import { compose } from 'redux';
+import { reduxForm, reset, startSubmit, stopSubmit, InjectedFormProps, Field } from 'redux-form';
+import { withDialog, WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { DialogContentText } from '@material-ui/core';
+import { TextField } from '~/components/text-field/text-field';
+import { RENAME_FILE_DIALOG, RenameFileDialogData, renameFile } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
-export const RENAME_FILE_DIALOG = 'renameFileDialog';
-
-export const openRenameFileDialog = (originalName: string) =>
- (dispatch: Dispatch) => {
- dispatch(reset(RENAME_FILE_DIALOG));
- dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data: originalName }));
- };
-
-export const [RenameFileDialog] = [RenameDialog]
- .map(withDialog(RENAME_FILE_DIALOG))
- .map(reduxForm({
+export const RenameFileDialog = compose(
+ withDialog(RENAME_FILE_DIALOG),
+ reduxForm({
form: RENAME_FILE_DIALOG,
- onSubmit: (data, dispatch) => {
- dispatch(startSubmit(RENAME_FILE_DIALOG));
- // TODO: call collection file renaming action here
- setTimeout(() => dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Invalid name' })), 2000);
+ onSubmit: (data: { name: string }, dispatch) => {
+ dispatch<any>(renameFile(data.name));
}
- }));
+ })
+)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string }>) =>
+ <FormDialog
+ dialogTitle='Rename'
+ formFields={RenameDialogFormFields}
+ submitLabel='Ok'
+ {...props}
+ />);
+
+const RenameDialogFormFields = (props: WithDialogProps<RenameFileDialogData>) => <>
+ <DialogContentText>
+ {`Please, enter a new name for ${props.data.name}`}
+ </DialogContentText>
+ <Field
+ name='name'
+ component={TextField}
+ />
+</>;
// SPDX-License-Identifier: AGPL-3.0
import { connect } from "react-redux";
-import { Tree, TreeProps, TreeItem } from "~/components/tree/tree";
+import { Tree, TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
import { RootState } from "~/store/store";
-import { TreePicker as TTreePicker, TreePickerNode, createTreePickerNode } from "~/store/tree-picker/tree-picker";
-import { getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTreePickerNode, TreePickerNode } from "~/store/tree-picker/tree-picker";
+import { getNodeValue, getNodeChildrenIds, Tree as Ttree, createTree } from "~/models/tree";
+import { Dispatch } from "redux";
-const memoizedMapStateToProps = () => {
- let prevState: TTreePicker;
- let prevTree: Array<TreeItem<any>>;
+export interface TreePickerProps {
+ pickerId: string;
+ toggleItemOpen: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
+ toggleItemActive: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
+}
- return (state: RootState): Pick<TreeProps<any>, 'items'> => {
- if (prevState !== state.treePicker) {
- prevState = state.treePicker;
- prevTree = getNodeChildren('')(state.treePicker)
- .map(treePickerToTreeItems(state.treePicker));
- }
- return {
- items: prevTree
- };
+const mapStateToProps = (state: RootState, props: TreePickerProps): Pick<TreeProps<any>, 'items'> => {
+ const tree = state.treePicker[props.pickerId] || createTree();
+ return {
+ items: getNodeChildrenIds('')(tree)
+ .map(treePickerToTreeItems(tree))
};
};
-const mapDispatchToProps = (): Pick<TreeProps<any>, 'onContextMenu'> => ({
+const mapDispatchToProps = (dispatch: Dispatch, props: TreePickerProps): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive'> => ({
onContextMenu: () => { return; },
+ toggleItemActive: (id, status) => props.toggleItemActive(id, status, props.pickerId),
+ toggleItemOpen: (id, status) => props.toggleItemOpen(id, status, props.pickerId)
});
-export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
-const treePickerToTreeItems = (tree: TTreePicker) =>
+const treePickerToTreeItems = (tree: Ttree<TreePickerNode>) =>
(id: string): TreeItem<any> => {
- const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ id: '', value: 'InvalidNode' });
- const items = getNodeChildren(node.id)(tree)
+ const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ nodeId: '', value: 'InvalidNode' });
+ const items = getNodeChildrenIds(node.nodeId)(tree)
.map(treePickerToTreeItems(tree));
return {
active: node.selected,
data: node.value,
- id: node.id,
+ id: node.nodeId,
items: items.length > 0 ? items : undefined,
open: !node.collapsed,
status: node.status
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch, compose } from "redux";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { FilesUploadDialog } from '~/components/file-upload-dialog/file-upload-dialog';
+import { RootState } from '../../store/store';
+import { uploadCurrentCollectionFiles, UPLOAD_COLLECTION_FILES_DIALOG, collectionUploaderActions } from '~/store/collections/uploader/collection-uploader-actions';
+
+const mapStateToProps = (state: RootState) => ({
+ files: state.collections.uploader,
+ uploading: state.collections.uploader.some(file => file.loaded < file.total)
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+ onSubmit: () => {
+ dispatch<any>(uploadCurrentCollectionFiles());
+ },
+ onChange: (files: File[]) => {
+ dispatch(collectionUploaderActions.SET_UPLOAD_FILES(files));
+ }
+});
+
+export const UploadCollectionFilesDialog = compose(
+ withDialog(UPLOAD_COLLECTION_FILES_DIALOG),
+ connect(mapStateToProps, mapDispatchToProps)
+)(FilesUploadDialog);
\ No newline at end of file
import * as React from 'react';
import {
StyleRulesCallback, WithStyles, withStyles, Card,
- CardHeader, IconButton, CardContent, Grid, Chip
+ CardHeader, IconButton, CardContent, Grid, Chip, Tooltip
} from '@material-ui/core';
import { connect, DispatchProp } from "react-redux";
import { RouteComponentProps } from 'react-router';
import { TagResource } from '~/models/tag';
import { CollectionTagForm } from './collection-tag-form';
import { deleteCollectionTag } from '~/store/collection-panel/collection-panel-action';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
-type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'value';
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'label' | 'value';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
card: {
color: theme.palette.grey["500"],
cursor: 'pointer'
},
+ label: {
+ fontSize: '0.875rem'
+ },
value: {
- textTransform: 'none'
+ textTransform: 'none',
+ fontSize: '0.875rem'
}
});
<CardContent>
<Grid container direction="column">
<Grid item xs={6}>
- <DetailsAttribute classValue={classes.value}
- label='Collection UUID'
- value={item && item.uuid}>
- <CopyToClipboard text={item && item.uuid}>
- <CopyIcon className={classes.copyIcon} />
- </CopyToClipboard>
- </DetailsAttribute>
- <DetailsAttribute label='Number of files' value='14' />
- <DetailsAttribute label='Content size' value='54 MB' />
- <DetailsAttribute classValue={classes.value} label='Owner' value={item && item.ownerUuid} />
+ <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+ label='Collection UUID'
+ value={item && item.uuid}>
+ <Tooltip title="Copy uuid">
+ <CopyToClipboard text={item && item.uuid} onCopy={() => this.onCopy() }>
+ <CopyIcon className={classes.copyIcon} />
+ </CopyToClipboard>
+ </Tooltip>
+ </DetailsAttribute>
+ <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+ label='Number of files' value='14' />
+ <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+ label='Content size' value='54 MB' />
+ <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+ label='Owner' value={item && item.ownerUuid} />
</Grid>
</Grid>
</CardContent>
this.props.dispatch<any>(deleteCollectionTag(uuid));
}
+ onCopy = () => {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Uuid has been copied",
+ hideDuration: 2000
+ }));
+ }
+
componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
if (!item || match.params.id !== item.uuid) {
onItemRouteChange(match.params.id);
import { FileRemoveDialog } from '~/views-components/file-remove-dialog/file-remove-dialog';
import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog/multiple-files-remove-dialog';
import { DialogCollectionCreateWithSelectedFile } from '~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected';
+import { MoveToProjectDialog } from '../../views-components/move-to-dialog/move-to-dialog';
import { COLLECTION_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-collection-create';
import { PROJECT_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-project-create';
+import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog';
+import { ProjectCopyDialog } from '~/views-components/project-copy-dialog/project-copy-dialog';
+import { CollectionPartialCopyDialog } from '../../views-components/collection-partial-copy-dialog/collection-partial-copy-dialog';
const DRAWER_WITDH = 240;
const APP_BAR_HEIGHT = 100;
<main className={classes.contentWrapper}>
<div className={classes.content}>
<Switch>
- <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`} />} />
+ <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`} />} />
<Route path="/projects/:id" render={this.renderProjectPanel} />
<Route path="/favorites" render={this.renderFavoritePanel} />
<Route path="/collections/:id" render={this.renderCollectionPanel} />
<CreateProjectDialog />
<CreateCollectionDialog />
<RenameFileDialog />
+ <CollectionPartialCopyDialog />
+ <MoveToProjectDialog />
<DialogCollectionCreateWithSelectedFile />
<FileRemoveDialog />
+ <ProjectCopyDialog />
<MultipleFilesRemoveDialog />
<UpdateCollectionDialog />
+ <UploadCollectionFilesDialog />
<UpdateProjectDialog />
<CurrentTokenDialog
currentToken={this.props.currentToken}