Merge branch 'master' into 13854-tags-card
authorJanicki Artur <artur.janicki@contractors.roche.com>
Tue, 7 Aug 2018 09:33:53 +0000 (11:33 +0200)
committerJanicki Artur <artur.janicki@contractors.roche.com>
Tue, 7 Aug 2018 09:33:53 +0000 (11:33 +0200)
refs #13854

Arvados-DCO-1.1-Signed-off-by: Janicki Artur <artur.janicki@contractors.roche.com>

12 files changed:
src/common/custom-theme.ts
src/components/details-attribute/details-attribute.tsx
src/models/link.ts
src/models/tag.ts [new file with mode: 0644]
src/services/services.ts
src/services/tag-service/tag-service.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-reducer.ts
src/validators/validators.tsx [new file with mode: 0644]
src/views/collection-panel/collection-panel.tsx
src/views/collection-panel/collection-tag-form.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index ecad39134d1652e07d11b58d8da524fdad7cca29..098e0090cc90bc896cd795169ddb32e8582fb817 100644 (file)
@@ -34,6 +34,7 @@ const grey500 = grey["500"];
 const grey600 = grey["600"];
 const grey700 = grey["700"];
 const grey900 = grey["900"];
+const rocheBlue = '#06C';
 
 const themeOptions: ArvadosThemeOptions = {
     customs: {
@@ -122,7 +123,7 @@ const themeOptions: ArvadosThemeOptions = {
     },
     palette: {
         primary: {
-            main: '#06C',
+            main: rocheBlue,
             dark: blue.A100
         }
     }
index 56da6c177cba95484171c55506b67411215021eb..f9a5b05abfdb4ab6f62c31be5263cd701bbc4479 100644 (file)
@@ -6,6 +6,7 @@ import * as React from 'react';
 import Typography from '@material-ui/core/Typography';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from '../../common/custom-theme';
+import * as classnames from "classnames";
 
 type CssRules = 'attribute' | 'label' | 'value' | 'link';
 
@@ -35,19 +36,21 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 interface DetailsAttributeDataProps {
     label: string;
+    classLabel?: string;
     value?: string | number;
+    classValue?: string;
     link?: string;
     children?: React.ReactNode;
 }
 
 type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules>;
 
-export const DetailsAttribute = withStyles(styles)(({ label, link, value, children, classes }: DetailsAttributeProps) =>
+export const DetailsAttribute = withStyles(styles)(({ label, link, value, children, classes, classLabel, classValue }: DetailsAttributeProps) =>
     <Typography component="div" className={classes.attribute}>
-        <Typography component="span" className={classes.label}>{label}</Typography>
+        <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={classes.value}>
+            : <Typography component="span" className={classnames([classes.value, classValue])}>
                 {value}
                 {children}
             </Typography> }
index 868652809c6485b3a1d90e96e6d19d088f46f0df..da9dfd030b39217f7649d6d896f9cfd3ad639a5e 100644 (file)
@@ -13,5 +13,6 @@ export interface LinkResource extends Resource {
 }
 
 export enum LinkClass {
-    STAR = 'star'
+    STAR = 'star',
+    TAG = 'tag'
 }
\ No newline at end of file
diff --git a/src/models/tag.ts b/src/models/tag.ts
new file mode 100644 (file)
index 0000000..9c229af
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource } from "./link";
+
+export interface TagResource extends LinkResource {
+    tailUuid: TagTailType;
+    properties: TagProperty;
+}
+
+export interface TagProperty {
+    key: string;
+    value: string;
+}
+
+export enum TagTailType {
+    COLLECTION = 'Collection',
+    JOB = 'Job'
+}
\ No newline at end of file
index 9e1adbf6e4e20d8a1f59637ae4a02f356905dc18..87c668f2ae4ca3ff3e9b36e0f6bb16d3c51a96e8 100644 (file)
@@ -9,6 +9,7 @@ import { LinkService } from "./link-service/link-service";
 import { FavoriteService } from "./favorite-service/favorite-service";
 import { AxiosInstance } from "axios";
 import { CollectionService } from "./collection-service/collection-service";
+import { TagService } from "./tag-service/tag-service";
 import Axios from "axios";
 import { CollectionFilesService } from "./collection-files-service/collection-files-service";
 
@@ -20,6 +21,7 @@ export interface ServiceRepository {
     projectService: ProjectService;
     linkService: LinkService;
     favoriteService: FavoriteService;
+    tagService: TagService;
     collectionService: CollectionService;
     collectionFilesService: CollectionFilesService;
 }
@@ -34,6 +36,7 @@ export const createServices = (baseUrl: string): ServiceRepository => {
     const linkService = new LinkService(apiClient);
     const favoriteService = new FavoriteService(linkService, groupsService);
     const collectionService = new CollectionService(apiClient);
+    const tagService = new TagService(linkService);
     const collectionFilesService = new CollectionFilesService(collectionService);
 
     return {
@@ -44,6 +47,7 @@ export const createServices = (baseUrl: string): ServiceRepository => {
         linkService,
         favoriteService,
         collectionService,
+        tagService,
         collectionFilesService
     };
 };
diff --git a/src/services/tag-service/tag-service.ts b/src/services/tag-service/tag-service.ts
new file mode 100644 (file)
index 0000000..c3019a6
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { LinkClass } from "../../models/link";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { TagTailType, TagResource } from "../../models/tag";
+import { OrderBuilder } from "../../common/api/order-builder";
+
+export class TagService {
+
+    constructor(private linkService: LinkService) { }
+
+    create(uuid: string, data: { key: string; value: string } ) {
+        return this.linkService
+            .create({
+                headUuid: uuid,
+                tailUuid: TagTailType.COLLECTION,
+                linkClass: LinkClass.TAG,
+                name: '',
+                properties: data
+            })
+            .then(tag => tag as TagResource );
+    }
+
+    list(uuid: string) {
+        const filters = FilterBuilder
+            .create<TagResource>()
+            .addEqual("headUuid", uuid)
+            .addEqual("tailUuid", TagTailType.COLLECTION)
+            .addEqual("linkClass", LinkClass.TAG);
+
+        const order = OrderBuilder
+            .create<TagResource>()
+            .addAsc('createdAt');
+
+        return this.linkService
+            .list({ filters, order })
+            .then(results => {
+                return results.items.map((tag => tag as TagResource ));
+            });
+    }
+
+}
\ No newline at end of file
index ee95590c064446d3d614c2bc2b9640f293bf8aba..f2774f6fb384f2d72256e57a93bd9cda92d71493 100644 (file)
@@ -10,14 +10,24 @@ import { collectionPanelFilesAction } from "./collection-panel-files/collection-
 import { createTree } from "../../models/tree";
 import { RootState } from "../store";
 import { ServiceRepository } from "../../services/services";
+import { TagResource, TagProperty } from "../../models/tag";
+import { snackbarActions } from "../snackbar/snackbar-actions";
 
 export const collectionPanelActions = unionize({
     LOAD_COLLECTION: ofType<{ uuid: string, kind: ResourceKind }>(),
-    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>()
+    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>(),
+    LOAD_COLLECTION_TAGS: ofType<{ uuid: string }>(),
+    LOAD_COLLECTION_TAGS_SUCCESS: ofType<{ tags: TagResource[] }>(),
+    CREATE_COLLECTION_TAG: ofType<{ data: any }>(),
+    CREATE_COLLECTION_TAG_SUCCESS: ofType<{ tag: TagResource }>(),
+    DELETE_COLLECTION_TAG: ofType<{ uuid: string }>(),
+    DELETE_COLLECTION_TAG_SUCCESS: ofType<{ uuid: string }>()
 }, { tag: 'type', value: 'payload' });
 
 export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
 
+export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm';
+
 export const loadCollection = (uuid: string, kind: ResourceKind) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid, kind }));
@@ -33,5 +43,43 @@ export const loadCollection = (uuid: string, kind: ResourceKind) =>
             });
     };
 
+export const loadCollectionTags = (uuid: string) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.LOAD_COLLECTION_TAGS({ uuid }));
+        return services.tagService
+            .list(uuid)
+            .then(tags => {
+                dispatch(collectionPanelActions.LOAD_COLLECTION_TAGS_SUCCESS({ tags }));
+            });
+    };
+
 
+export const createCollectionTag = (data: TagProperty) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.CREATE_COLLECTION_TAG({ data }));
+        const item = getState().collectionPanel.item;
+        const uuid = item ? item.uuid : '';
+        return services.tagService
+            .create(uuid, data)
+            .then(tag => {
+                dispatch(collectionPanelActions.CREATE_COLLECTION_TAG_SUCCESS({ tag }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Tag has been successfully added.",
+                    hideDuration: 2000
+                }));
+            });
+    };
 
+export const deleteCollectionTag = (uuid: string) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.DELETE_COLLECTION_TAG({ uuid }));
+        return services.linkService
+            .delete(uuid)
+            .then(tag => {
+                dispatch(collectionPanelActions.DELETE_COLLECTION_TAG_SUCCESS({ uuid: tag.uuid }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Tag has been successfully deleted.",
+                    hideDuration: 2000
+                }));
+            });
+    };
\ No newline at end of file
index 0dd233eaf0c4c444d4f7fc50f8eb218e3aa50d16..44b778980bafa68eade5afb259880b5a7e7bf1e2 100644 (file)
@@ -4,18 +4,23 @@
 
 import { collectionPanelActions, CollectionPanelAction } from "./collection-panel-action";
 import { CollectionResource } from "../../models/collection";
+import { TagResource } from "../../models/tag";
 
 export interface CollectionPanelState {
     item: CollectionResource | null;
+    tags: TagResource[];
 }
 
 const initialState = {
-    item: null
+    item: null,
+    tags: []
 };
 
 export const collectionPanelReducer = (state: CollectionPanelState = initialState, action: CollectionPanelAction) =>
     collectionPanelActions.match(action, {
         default: () => state,
-        LOAD_COLLECTION: () => state,
         LOAD_COLLECTION_SUCCESS: ({ item }) => ({ ...state, item }),
+        LOAD_COLLECTION_TAGS_SUCCESS: ({ tags }) => ({...state, tags }),
+        CREATE_COLLECTION_TAG_SUCCESS: ({ tag }) => ({...state, tags: [...state.tags, tag] }),
+        DELETE_COLLECTION_TAG_SUCCESS: ({ uuid }) => ({...state, tags: state.tags.filter(tag => tag.uuid !== uuid) })
     });
diff --git a/src/validators/validators.tsx b/src/validators/validators.tsx
new file mode 100644 (file)
index 0000000..fdeb8fa
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { require } from './require';
+import { maxLength } from './max-length';
+
+export const TAG_KEY_VALIDATION = [require, maxLength(255)];
+export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
\ No newline at end of file
index 43423a62f2c66a56d759cf304ad122e98d2adcf8..489d28473b0803fe3887380c850186f05c707680 100644 (file)
@@ -7,7 +7,7 @@ import {
     StyleRulesCallback, WithStyles, withStyles, Card,
     CardHeader, IconButton, CardContent, Grid, Chip
 } from '@material-ui/core';
-import { connect } from 'react-redux';
+import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
 import { ArvadosTheme } from '../../common/custom-theme';
 import { RootState } from '../../store/store';
@@ -16,29 +16,38 @@ import { DetailsAttribute } from '../../components/details-attribute/details-att
 import { CollectionResource } from '../../models/collection';
 import { CollectionPanelFiles } from '../../views-components/collection-panel-files/collection-panel-files';
 import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { TagResource } from '../../models/tag';
+import { CollectionTagForm } from './collection-tag-form';
+import { deleteCollectionTag } from '../../store/collection-panel/collection-panel-action';
 
-type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon';
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'value';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
-        marginBottom: '20px'
+        marginBottom: theme.spacing.unit * 2
     },
     iconHeader: {
         fontSize: '1.875rem',
         color: theme.customs.colors.yellow700
     },
     tag: {
-        marginRight: theme.spacing.unit
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
     },
     copyIcon: {
         marginLeft: theme.spacing.unit,
         fontSize: '1.125rem',
+        color: theme.palette.grey["500"],
         cursor: 'pointer'
+    },
+    value: {
+        textTransform: 'none'
     }
 });
 
 interface CollectionPanelDataProps {
     item: CollectionResource;
+    tags: TagResource[];
 }
 
 interface CollectionPanelActionProps {
@@ -46,16 +55,19 @@ interface CollectionPanelActionProps {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: CollectionResource) => void;
 }
 
-type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps
+type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps & DispatchProp
                             & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 
 export const CollectionPanel = withStyles(styles)(
-    connect((state: RootState) => ({ item: state.collectionPanel.item! }))(
-        class extends React.Component<CollectionPanelProps> {
+    connect((state: RootState) => ({ 
+        item: state.collectionPanel.item, 
+        tags: state.collectionPanel.tags 
+    }))(
+        class extends React.Component<CollectionPanelProps> { 
 
             render() {
-                const { classes, item, onContextMenu } = this.props;
+                const { classes, item, tags, onContextMenu } = this.props;
                 return <div>
                         <Card className={classes.card}>
                             <CardHeader
@@ -72,13 +84,16 @@ export const CollectionPanel = withStyles(styles)(
                             <CardContent>
                                 <Grid container direction="column">
                                     <Grid item xs={6}>
-                                    <DetailsAttribute label='Collection UUID' value={item && item.uuid}>
+                                    <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 label='Owner' value={item && item.ownerUuid} />
+                                    <DetailsAttribute classValue={classes.value} label='Owner' value={item && item.ownerUuid} />
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -88,10 +103,15 @@ export const CollectionPanel = withStyles(styles)(
                             <CardHeader title="Tags" />
                             <CardContent>
                                 <Grid container direction="column">
-                                    <Grid item xs={4}>
-                                        <Chip label="Tag 1" className={classes.tag}/>
-                                        <Chip label="Tag 2" className={classes.tag}/>
-                                        <Chip label="Tag 3" className={classes.tag}/>
+                                    <Grid item xs={12}><CollectionTagForm /></Grid>
+                                    <Grid item xs={12}>
+                                        {
+                                            tags.map(tag => {
+                                                return <Chip key={tag.etag} className={classes.tag}
+                                                    onDelete={this.handleDelete(tag.uuid)}
+                                                    label={renderTagLabel(tag)}  />;
+                                            })
+                                        }
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -102,6 +122,10 @@ export const CollectionPanel = withStyles(styles)(
                     </div>;
             }
 
+            handleDelete = (uuid: string) => () => {
+                this.props.dispatch<any>(deleteCollectionTag(uuid));
+            }
+
             componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
                 if (!item || match.params.id !== item.uuid) {
                     onItemRouteChange(match.params.id);
@@ -111,3 +135,8 @@ export const CollectionPanel = withStyles(styles)(
         }
     )
 );
+
+const renderTagLabel = (tag: TagResource) => {
+    const { properties } = tag;
+    return `${properties.key}: ${properties.value}`;
+};
diff --git a/src/views/collection-panel/collection-tag-form.tsx b/src/views/collection-panel/collection-tag-form.tsx
new file mode 100644 (file)
index 0000000..89cf880
--- /dev/null
@@ -0,0 +1,118 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field, reset } from 'redux-form';
+import { compose, Dispatch } from 'redux';
+import { ArvadosTheme } from '../../common/custom-theme';
+import { StyleRulesCallback, withStyles, WithStyles, TextField, Button, CircularProgress } from '@material-ui/core';
+import { TagProperty } from '../../models/tag';
+import { createCollectionTag, COLLECTION_TAG_FORM_NAME } from '../../store/collection-panel/collection-panel-action';
+import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '../../validators/validators';
+
+type CssRules = 'form' | 'textField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    form: {
+        marginBottom: theme.spacing.unit * 4 
+    },
+    textField: {
+        marginRight: theme.spacing.unit
+    },
+    buttonWrapper: {
+        position: 'relative',
+        display: 'inline-block'
+    },
+    saveButton: {
+        boxShadow: 'none'
+    },
+    circularProgress: {
+        position: 'absolute',
+        top: 0,
+        bottom: 0,
+        left: 0,
+        right: 0,
+        margin: 'auto'
+    }
+});
+
+interface CollectionTagFormDataProps {
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+}
+
+interface CollectionTagFormActionProps {
+    handleSubmit: any;
+}
+
+interface TextFieldProps {
+    label: string;
+    floatinglabeltext: string;
+    className?: string;
+    input?: string;
+    meta?: any;
+}
+
+type CollectionTagFormProps = CollectionTagFormDataProps & CollectionTagFormActionProps & WithStyles<CssRules>;
+
+export const CollectionTagForm = compose(
+    reduxForm({ 
+        form: COLLECTION_TAG_FORM_NAME, 
+        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
+            dispatch<any>(createCollectionTag(data));
+            dispatch(reset(COLLECTION_TAG_FORM_NAME));
+        } 
+    }),
+    withStyles(styles))(
+        
+    class CollectionTagForm extends React.Component<CollectionTagFormProps> {
+
+            render() {
+                const { classes, submitting, pristine, invalid, handleSubmit } = this.props;
+                return (
+                    <form className={classes.form} onSubmit={handleSubmit}>
+                        <Field name="key"
+                            disabled={submitting}
+                            component={this.renderTextField}
+                            floatinglabeltext="Key"
+                            validate={TAG_KEY_VALIDATION}
+                            className={classes.textField}
+                            label="Key" />
+                        <Field name="value"
+                            disabled={submitting}
+                            component={this.renderTextField}
+                            floatinglabeltext="Value"
+                            validate={TAG_VALUE_VALIDATION}
+                            className={classes.textField}
+                            label="Value" />
+                        <div className={classes.buttonWrapper}>
+                            <Button type="submit" className={classes.saveButton}
+                                color="primary"
+                                size='small'
+                                disabled={invalid || submitting || pristine}
+                                variant="contained">
+                                ADD
+                            </Button>
+                            {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
+                        </div>
+                    </form>
+                );
+            }
+
+            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}
+                />
+            )
+
+        }
+
+    );
\ No newline at end of file
index 3611d7b10fb12210f9573f9ebf2df41476d62693..69d809869995d3c3344a043b0901076ec9633add 100644 (file)
@@ -34,12 +34,11 @@ import { ResourceKind } from '../../models/resource';
 import { ContextMenu, ContextMenuKind } from "../../views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
 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 { favoritePanelActions } from '../../store/favorite-panel/favorite-panel-action';
 import { CreateCollectionDialog } from '../../views-components/create-collection-dialog/create-collection-dialog';
 import { CollectionPanel } from '../collection-panel/collection-panel';
-import { loadCollection } from '../../store/collection-panel/collection-panel-action';
+import { loadCollection, loadCollectionTags } from '../../store/collection-panel/collection-panel-action';
 import { getCollectionUrl } from '../../models/collection';
 import { RemoveDialog } from '../../views-components/remove-dialog/remove-dialog';
 import { RenameDialog } from '../../views-components/rename-dialog/rename-dialog';
@@ -245,8 +244,11 @@ export const Workbench = withStyles(styles)(
                 );
             }
 
-            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
-                onItemRouteChange={(collectionId) => this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION))}
+            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel 
+                onItemRouteChange={(collectionId) => {
+                    this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION));
+                    this.props.dispatch<any>(loadCollectionTags(collectionId));
+                }}
                 onContextMenu={(event, item) => {
                     this.openContextMenu(event, {
                         uuid: item.uuid,