--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "../../common/api/common-resource-service";
+import { CollectionResource } from "../../models/collection";
+import { AxiosInstance } from "axios";
+
+export class CollectionCreationService extends CommonResourceService<CollectionResource> {
+ constructor(serverApi: AxiosInstance) {
+ super(serverApi, "collections");
+ }
+}
\ No newline at end of file
import { ProjectService } from "./project-service/project-service";
import { LinkService } from "./link-service/link-service";
import { FavoriteService } from "./favorite-service/favorite-service";
+import { CollectionCreationService } from "./collection-service/collection-service";
export const authService = new AuthService(authClient, apiClient);
export const groupsService = new GroupsService(apiClient);
export const projectService = new ProjectService(apiClient);
+export const collectionCreationService = new CollectionCreationService(apiClient);
export const linkService = new LinkService(apiClient);
export const favoriteService = new FavoriteService(linkService, groupsService);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+
+import { RootState } from "../../store";
+import { collectionCreationService } from '../../../services/services';
+import { CollectionResource } from '../../../models/collection';
+
+export const collectionCreateActions = unionize({
+ OPEN_COLLECTION_CREATOR: ofType<{ ownerUuid: string }>(),
+ CLOSE_COLLECTION_CREATOR: ofType<{}>(),
+ CREATE_COLLECTION: ofType<{}>(),
+ CREATE_COLLECTION_SUCCESS: ofType<{}>(),
+}, {
+ tag: 'type',
+ value: 'payload'
+ });
+
+export const createCollection = (collection: Partial<CollectionResource>) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const { ownerUuid } = getState().collectionCreation.creator;
+ const collectiontData = { ownerUuid, ...collection };
+ dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
+ return collectionCreationService
+ .create(collectiontData)
+ .then(collection => dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection)));
+ };
+
+export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionCreateActions, CollectionCreateAction } from './collection-creator-action';
+
+export type CollectionCreatorState = {
+ creator: CollectionCreator
+};
+
+interface CollectionCreator {
+ opened: boolean;
+ pending: boolean;
+ ownerUuid: string;
+}
+
+const updateCreator = (state: CollectionCreatorState, creator: Partial<CollectionCreator>) => ({
+ ...state,
+ creator: {
+ ...state.creator,
+ ...creator
+ }
+});
+
+const initialState: CollectionCreatorState = {
+ creator: {
+ opened: false,
+ pending: false,
+ ownerUuid: ""
+ }
+};
+
+export const collectionCreationReducer = (state: CollectionCreatorState = initialState, action: CollectionCreateAction) => {
+ return collectionCreateActions.match(action, {
+ OPEN_COLLECTION_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true, pending: false }),
+ CLOSE_COLLECTION_CREATOR: () => updateCreator(state, { opened: false }),
+ CREATE_COLLECTION: () => updateCreator(state, { opened: true }),
+ CREATE_COLLECTION_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
+ default: () => state
+ });
+};
import { reducer as formReducer } from 'redux-form';
import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
import { snackbarReducer, SnackbarState } from './snackbar/snackbar-reducer';
+import { CollectionCreatorState, collectionCreationReducer } from './collections/creator/collection-creator-reducer';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
export interface RootState {
auth: AuthState;
projects: ProjectState;
+ collectionCreation: CollectionCreatorState;
router: RouterState;
dataExplorer: DataExplorerState;
sidePanel: SidePanelState;
const rootReducer = combineReducers({
auth: authReducer,
projects: projectsReducer,
+ collectionCreation: collectionCreationReducer,
router: routerReducer,
dataExplorer: dataExplorerReducer,
sidePanel: sidePanelReducer,
export const PROJECT_NAME_VALIDATION = [require, maxLength(255)];
export const PROJECT_DESCRIPTION_VALIDATION = [maxLength(255)];
+export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
+export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { SubmissionError } from "redux-form";
+
+import { RootState } from "../../store/store";
+import { DialogCollectionCreate } from "../dialog-create/dialog-collection-create";
+import { collectionCreateActions, createCollection } from "../../store/collections/creator/collection-creator-action";
+import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
+import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+
+const mapStateToProps = (state: RootState) => ({
+ open: state.collectionCreation.creator.opened
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+ handleClose: () => {
+ dispatch(collectionCreateActions.CLOSE_COLLECTION_CREATOR());
+ },
+ onSubmit: (data: { name: string, description: string }) => {
+ return dispatch<any>(addCollection(data))
+ .catch((e: any) => {
+ throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
+ });
+ }
+});
+
+const addCollection = (data: { name: string, description: string }) =>
+ (dispatch: Dispatch) => {
+ return dispatch<any>(createCollection(data)).then(() => {
+ dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+ });
+ };
+
+export const CreateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionCreate);
+
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field } from 'redux-form';
+import { compose } from 'redux';
+import TextField from '@material-ui/core/TextField';
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-project/create-project-validator';
+
+type CssRules = "button" | "lastButton" | "formContainer" | "textField" | "dialog" | "dialogTitle" | "createProgress" | "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"
+ },
+ textField: {
+ marginTop: "32px",
+ },
+ dialog: {
+ minWidth: "600px",
+ minHeight: "320px"
+ },
+ createProgress: {
+ position: "absolute",
+ minWidth: "20px",
+ right: "95px"
+ },
+ dialogActions: {
+ marginBottom: "24px"
+ }
+});
+interface DialogCollectionCreateProps {
+ open: boolean;
+ handleClose: () => void;
+ onSubmit: (data: { name: string, description: string }) => void;
+ handleSubmit: any;
+ submitting: boolean;
+ invalid: boolean;
+ pristine: boolean;
+}
+
+interface TextFieldProps {
+ label: string;
+ floatinglabeltext: string;
+ className?: string;
+ input?: string;
+ meta?: any;
+}
+
+export const DialogCollectionCreate = compose(
+ reduxForm({ form: 'collectionCreateDialog' }),
+ withStyles(styles))(
+ class DialogCollectionCreate extends React.Component<DialogCollectionCreateProps & WithStyles<CssRules>> {
+ render() {
+ const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
+
+ return (
+ <Dialog
+ open={open}
+ onClose={handleClose}
+ disableBackdropClick={true}
+ disableEscapeKeyDown={true}>
+ <div className={classes.dialog}>
+ <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
+ <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a collection</DialogTitle>
+ <DialogContent className={classes.formContainer}>
+ <Field name="name"
+ component={this.renderTextField}
+ floatinglabeltext="Collection Name"
+ validate={COLLECTION_NAME_VALIDATION}
+ className={classes.textField}
+ label="Collection Name"/>
+ <Field name="description"
+ component={this.renderTextField}
+ floatinglabeltext="Description - optional"
+ validate={COLLECTION_DESCRIPTION_VALIDATION}
+ className={classes.textField}
+ label="Description - optional"/>
+ </DialogContent>
+ <DialogActions className={classes.dialogActions}>
+ <Button onClick={handleClose} className={classes.button} color="primary"
+ disabled={submitting}>CANCEL</Button>
+ <Button type="submit"
+ className={classes.lastButton}
+ color="primary"
+ disabled={invalid|| submitting || pristine}
+ variant="contained">
+ CREATE A COLLECTION
+ </Button>
+ {submitting && <CircularProgress size={20} className={classes.createProgress}/>}
+ </DialogActions>
+ </form>
+ </div>
+ </Dialog>
+ );
+ }
+
+ renderTextField = ({ input, label, meta: { touched, error }, ...custom }: TextFieldProps) => (
+ <TextField
+ helperText={touched && error}
+ label={label}
+ className={this.props.classes.textField}
+ error={touched && !!error}
+ autoComplete='off'
+ {...input}
+ {...custom}
+ />
+ )
+ }
+);
onSubmit: (data: { name: string, description: string }) => void;
handleSubmit: any;
submitting: boolean;
+ invalid: boolean;
+ pristine: boolean;
}
interface TextFieldProps {
withStyles(styles))(
class DialogProjectCreate extends React.Component<DialogProjectProps & WithStyles<CssRules>> {
render() {
- const { classes, open, handleClose, handleSubmit, onSubmit, submitting } = this.props;
+ const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
return (
<Dialog
<Button type="submit"
className={classes.lastButton}
color="primary"
- disabled={submitting}
+ disabled={invalid|| submitting || pristine}
variant="contained">
CREATE A PROJECT
</Button>
interface ProjectPanelActionProps {
onItemClick: (item: ProjectPanelItem) => void;
onContextMenu: (event: React.MouseEvent<HTMLElement>, item: ProjectPanelItem) => void;
- onDialogOpen: (ownerUuid: string) => void;
+ onProjectCreationDialogOpen: (ownerUuid: string) => void;
+ onCollectionCreationDialogOpen: (ownerUuid: string) => void;
onItemDoubleClick: (item: ProjectPanelItem) => void;
onItemRouteChange: (itemId: string) => void;
}
const { classes } = this.props;
return <div>
<div className={classes.toolbar}>
- <Button color="primary" variant="raised" className={classes.button}>
+ <Button color="primary" onClick={this.handleNewCollectionClick} variant="raised" className={classes.button}>
Create a collection
</Button>
<Button color="primary" variant="raised" className={classes.button}>
}
handleNewProjectClick = () => {
- this.props.onDialogOpen(this.props.currentItemId);
+ this.props.onProjectCreationDialogOpen(this.props.currentItemId);
+ }
+
+ handleNewCollectionClick = () => {
+ this.props.onCollectionCreationDialogOpen(this.props.currentItemId);
}
componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: ProjectPanelProps) {
if (match.params.id !== currentItemId) {
import { SidePanel, SidePanelItem } from '../../components/side-panel/side-panel';
import { ItemMode, setProjectItem } from "../../store/navigation/navigation-action";
import { projectActions } from "../../store/project/project-action";
+import { collectionCreateActions } from '../../store/collections/creator/collection-creator-action';
import { ProjectPanel } from "../project-panel/project-panel";
import { DetailsPanel } from '../../views-components/details-panel/details-panel';
import { ArvadosTheme } from '../../common/custom-theme';
import { CurrentTokenDialog } from '../../views-components/current-token-dialog/current-token-dialog';
import { dataExplorerActions } from '../../store/data-explorer/data-explorer-action';
import { Snackbar } from '../../views-components/snackbar/snackbar';
+import { CreateCollectionDialog } from '../../views-components/create-collection-dialog/create-collection-dialog';
const drawerWidth = 240;
const appBarHeight = 100;
<ContextMenu />
<Snackbar />
<CreateProjectDialog />
+ <CreateCollectionDialog />
<CurrentTokenDialog
currentToken={this.props.currentToken}
open={this.state.isCurrentTokenDialogOpen}
kind
});
}}
- onDialogOpen={this.handleCreationDialogOpen}
+ onProjectCreationDialogOpen={this.handleProjectCreationDialogOpen}
+ onCollectionCreationDialogOpen={this.handleCollectionCreationDialogOpen}
onItemClick={item => {
this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
}}
kind,
});
}}
- onDialogOpen={this.handleCreationDialogOpen}
+ onDialogOpen={this.handleProjectCreationDialogOpen}
onItemClick={item => {
this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
}}
}
}
- handleCreationDialogOpen = (itemUuid: string) => {
+ handleProjectCreationDialogOpen = (itemUuid: string) => {
this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
}
+ handleCollectionCreationDialogOpen = (itemUuid: string) => {
+ this.props.dispatch(collectionCreateActions.OPEN_COLLECTION_CREATOR({ ownerUuid: itemUuid }));
+ }
+
openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; kind: ContextMenuKind; }) => {
event.preventDefault();
this.props.dispatch(