const grey600 = grey["600"];
const grey700 = grey["700"];
const grey900 = grey["900"];
+const rocheBlue = '#06C';
const themeOptions: ArvadosThemeOptions = {
customs: {
},
palette: {
primary: {
- main: '#06C',
+ main: rocheBlue,
dark: blue.A100
}
}
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';
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> }
}
export enum LinkClass {
- STAR = 'star'
+ STAR = 'star',
+ TAG = 'tag'
}
\ No newline at end of file
--- /dev/null
+// 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
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";
projectService: ProjectService;
linkService: LinkService;
favoriteService: FavoriteService;
+ tagService: TagService;
collectionService: CollectionService;
collectionFilesService: CollectionFilesService;
}
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 {
linkService,
favoriteService,
collectionService,
+ tagService,
collectionFilesService
};
};
--- /dev/null
+// 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
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 }));
});
};
+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
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) })
});
--- /dev/null
+// 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
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';
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 {
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
<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>
<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>
</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);
}
)
);
+
+const renderTagLabel = (tag: TagResource) => {
+ const { properties } = tag;
+ return `${properties.key}: ${properties.value}`;
+};
--- /dev/null
+// 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
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';
);
}
- 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,