});
});
+ it('views & edits storage classes data', function () {
+ const colName= `Test Collection ${Math.floor(Math.random() * 999999)}`;
+ cy.createCollection(adminUser.token, {
+ name: colName,
+ owner_uuid: activeUser.user.uuid,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
+ }).as('collection').then(function () {
+ expect(this.collection.storage_classes_desired).to.deep.equal(['default'])
+
+ cy.loginAs(activeUser)
+ cy.goToPath(`/collections/${this.collection.uuid}`);
+
+ // Initial check: it should show the 'default' storage class
+ cy.get('[data-cy=collection-info-panel]')
+ .should('contain', 'Storage classes')
+ .and('contain', 'default')
+ .and('not.contain', 'foo')
+ .and('not.contain', 'bar');
+ // Edit collection: add storage class 'foo'
+ cy.get('[data-cy=collection-panel-options-btn]').click();
+ cy.get('[data-cy=context-menu]').contains('Edit collection').click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Edit Collection')
+ .and('contain', 'Storage classes')
+ .and('contain', 'default')
+ .and('contain', 'foo')
+ .and('contain', 'bar')
+ .within(() => {
+ cy.get('[data-cy=checkbox-foo]').click();
+ });
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=collection-info-panel]')
+ .should('contain', 'default')
+ .and('contain', 'foo')
+ .and('not.contain', 'bar');
+ cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
+ .its('body').as('updatedCollection')
+ .then(function () {
+ expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['default', 'foo']);
+ });
+ // Edit collection: remove storage class 'default'
+ cy.get('[data-cy=collection-panel-options-btn]').click();
+ cy.get('[data-cy=context-menu]').contains('Edit collection').click();
+ cy.get('[data-cy=form-dialog]')
+ .should('contain', 'Edit Collection')
+ .and('contain', 'Storage classes')
+ .and('contain', 'default')
+ .and('contain', 'foo')
+ .and('contain', 'bar')
+ .within(() => {
+ cy.get('[data-cy=checkbox-default]').click();
+ });
+ cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('[data-cy=collection-info-panel]')
+ .should('not.contain', 'default')
+ .and('contain', 'foo')
+ .and('not.contain', 'bar');
+ cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
+ .its('body').as('updatedCollection')
+ .then(function () {
+ expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['foo']);
+ });
+ })
+ });
+
it('uses the collection version browser to view a previous version', function () {
const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
}
}
};
+ Volumes: {
+ [key: string]: {
+ StorageClasses: {
+ [key: string]: boolean;
+ }
+ }
+ };
}
export class Config {
return config;
};
+export const getStorageClasses = (config: Config): string[] => {
+ const classes: Set<string> = new Set(['default']);
+ const volumes = config.clusterConfig.Volumes;
+ Object.keys(volumes).forEach(v => {
+ Object.keys(volumes[v].StorageClasses || {}).forEach(sc => {
+ if (volumes[v].StorageClasses[sc]) {
+ classes.add(sc);
+ }
+ });
+ });
+ return Array.from(classes);
+};
+
const getApiRevision = async (apiUrl: string) => {
try {
const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
Collections: {
ForwardSlashNameSubstitution: "",
},
+ Volumes: {},
...config
});
import React from 'react';
import { WrappedFieldProps } from 'redux-form';
-import { FormControlLabel, Checkbox } from '@material-ui/core';
+import {
+ FormControlLabel,
+ Checkbox,
+ FormControl,
+ FormGroup,
+ FormLabel,
+ FormHelperText
+} from '@material-ui/core';
export const CheckboxField = (props: WrappedFieldProps & { label?: string }) =>
<FormControlLabel
disabled={props.meta.submitting}
color="primary" />
}
- label={props.label}
- />;
\ No newline at end of file
+ label={props.label}
+ />;
+
+type MultiCheckboxFieldProps = {
+ items: string[];
+ label?: string;
+ minSelection?: number;
+ maxSelection?: number;
+ helperText?: string;
+ rowLayout?: boolean;
+}
+
+export const MultiCheckboxField = (props: WrappedFieldProps & MultiCheckboxFieldProps) => {
+ const isValid = (items: string[]) => (items.length >= (props.minSelection || 0)) &&
+ (items.length <= (props.maxSelection || items.length));
+ return <FormControl error={!isValid(props.input.value)}>
+ <FormLabel component='label'>{props.label}</FormLabel>
+ <FormGroup row={props.rowLayout}>
+ { props.items.map((item, idx) =>
+ <FormControlLabel
+ control={
+ <Checkbox
+ data-cy={`checkbox-${item}`}
+ key={idx}
+ name={`${props.input.name}[${idx}]`}
+ value={item}
+ checked={props.input.value.indexOf(item) !== -1}
+ onChange={e => {
+ const newValue = [...props.input.value];
+ if (e.target.checked) {
+ newValue.push(item);
+ } else {
+ newValue.splice(newValue.indexOf(item), 1);
+ }
+ if (!isValid(newValue)) { return; }
+ return props.input.onChange(newValue);
+ }}
+ disabled={props.meta.submitting}
+ color="primary" />
+ }
+ label={item} />) }
+ </FormGroup>
+ <FormHelperText>{props.helperText}</FormHelperText>
+ </FormControl> };
\ No newline at end of file
uuid: string;
name: string;
description?: string;
+ storageClassesDesired?: string[];
}
export const COLLECTION_UPDATE_FORM_NAME = 'collectionUpdateFormName';
services.collectionService.update(uuid, {
name: collection.name,
+ storageClassesDesired: collection.storageClassesDesired,
description: collection.description }
).then(updatedCollection => {
dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
isEditable?: boolean;
outputUuid?: string;
workflowUuid?: string;
+ storageClassesDesired?: string[];
};
export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
dispatch<any>(openContextMenu(event, {
name: collection.name,
uuid: collection.uuid,
+ description: collection.description,
+ storageClassesDesired: collection.storageClassesDesired,
ownerUuid: collection.ownerUuid,
isTrashed: collection.isTrashed,
kind: collection.kind,
//
// SPDX-License-Identifier: AGPL-3.0
-import { compose } from "redux";
+import { compose, Dispatch } from "redux";
import { reduxForm } from 'redux-form';
import { withDialog } from "store/dialog/with-dialog";
import { DialogCollectionUpdate } from 'views-components/dialog-update/dialog-collection-update';
-import { COLLECTION_UPDATE_FORM_NAME, CollectionUpdateFormDialogData, updateCollection } from 'store/collections/collection-update-actions';
+import {
+ COLLECTION_UPDATE_FORM_NAME,
+ CollectionUpdateFormDialogData,
+ updateCollection
+} from 'store/collections/collection-update-actions';
export const UpdateCollectionDialog = compose(
withDialog(COLLECTION_UPDATE_FORM_NAME),
reduxForm<CollectionUpdateFormDialogData>({
touchOnChange: true,
form: COLLECTION_UPDATE_FORM_NAME,
- onSubmit: (data, dispatch) => {
- dispatch(updateCollection(data));
+ onSubmit: (data: CollectionUpdateFormDialogData, dispatch: Dispatch) => {
+ dispatch<any>(updateCollection(data));
}
})
)(DialogCollectionUpdate);
\ No newline at end of file
import { WithDialogProps } from 'store/dialog/with-dialog';
import { CollectionUpdateFormDialogData } from 'store/collections/collection-update-actions';
import { FormDialog } from 'components/form-dialog/form-dialog';
-import { CollectionNameField, CollectionDescriptionField } from 'views-components/form-fields/collection-form-fields';
+import {
+ CollectionNameField,
+ CollectionDescriptionField,
+ CollectionStorageClassesField
+} from 'views-components/form-fields/collection-form-fields';
type DialogCollectionProps = WithDialogProps<{}> & InjectedFormProps<CollectionUpdateFormDialogData>;
const CollectionEditFields = () => <span>
<CollectionNameField />
<CollectionDescriptionField />
+ <CollectionStorageClassesField />
</span>;
import { PickerIdProp } from 'store/tree-picker/picker-id';
import { connect } from "react-redux";
import { RootState } from "store/store";
+import { MultiCheckboxField } from "components/checkbox-field/checkbox-field";
+import { getStorageClasses } from "common/config";
interface CollectionNameFieldProps {
validate: Validator[];
pickerId={props.pickerId}
component={CollectionTreePickerField}
validate={COLLECTION_PROJECT_VALIDATION} />;
+
+interface StorageClassesProps {
+ items: string[];
+}
+
+export const CollectionStorageClassesField = connect(
+ (state: RootState) => {
+ return {
+ items: getStorageClasses(state.auth.config)
+ };
+ })(
+ (props: StorageClassesProps) =>
+ <Field
+ name='storageClassesDesired'
+ label='Storage classes'
+ minSelection={1}
+ rowLayout={true}
+ helperText='At least one class should be selected'
+ component={MultiCheckboxField}
+ items={props.items} />);
\ No newline at end of file
ResourceLastModifiedDate,
ResourceStatus
} from 'views-components/data-explorer/renderers';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { RootState } from 'store/store';
+import { CollectionResource } from 'models/collection';
type CssRules = 'backLink' | 'backIcon' | 'card' | 'title' | 'iconHeader' | 'link';
}
];
-export interface CollectionContentAddressPanelActionProps {
- onContextMenu: (event: React.MouseEvent<any>, uuid: string) => void;
+interface CollectionContentAddressPanelActionProps {
+ onContextMenu: (resources: ResourcesState) => (event: React.MouseEvent<any>, uuid: string) => void;
onItemClick: (item: string) => void;
onItemDoubleClick: (item: string) => void;
}
+interface CollectionContentAddressPanelDataProps {
+ resources: ResourcesState;
+}
+
+const mapStateToProps = ({ resources }: RootState): CollectionContentAddressPanelDataProps => ({
+ resources
+})
+
const mapDispatchToProps = (dispatch: Dispatch): CollectionContentAddressPanelActionProps => ({
- onContextMenu: (event, resourceUuid) => {
+ onContextMenu: (resources: ResourcesState) => (event, resourceUuid) => {
+ const resource = getResource<CollectionResource>(resourceUuid)(resources);
const kind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
if (kind) {
dispatch<any>(openContextMenu(event, {
- name: '',
+ name: resource ? resource.name : '',
+ description: resource ? resource.description : '',
+ storageClassesDesired: resource ? resource.storageClassesDesired : [],
uuid: resourceUuid,
ownerUuid: '',
kind: ResourceKind.NONE,
}
export const CollectionsContentAddressPanel = withStyles(styles)(
- connect(null, mapDispatchToProps)(
- class extends React.Component<CollectionContentAddressPanelActionProps & CollectionContentAddressDataProps & WithStyles<CssRules>> {
+ connect(mapStateToProps, mapDispatchToProps)(
+ class extends React.Component<CollectionContentAddressPanelActionProps & CollectionContentAddressPanelDataProps & CollectionContentAddressDataProps & WithStyles<CssRules>> {
render() {
return <Grid item xs={12}>
<Button
hideSearchInput
onRowClick={this.props.onItemClick}
onRowDoubleClick={this.props.onItemDoubleClick}
- onContextMenu={this.props.onContextMenu}
+ onContextMenu={this.props.onContextMenu(this.props.resources)}
contextMenuColumn={true}
title={`Content address: ${this.props.match.params.id}`}
dataTableDefaultView={
}
handleContextMenu = (event: React.MouseEvent<any>) => {
- const { uuid, ownerUuid, name, description, kind } = this.props.item;
+ const { uuid, ownerUuid, name, description, kind, storageClassesDesired } = this.props.item;
const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(uuid));
const resource = {
uuid,
ownerUuid,
name,
description,
+ storageClassesDesired,
kind,
menuKind,
};
<DetailsAttribute classLabel={classes.label} classValue={classes.value}
label='Content size' value={formatFileSize(item.fileSizeTotal)} />
</Grid>
+ <Grid item xs={12} md={mdSize}>
+ <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+ label='Storage classes' value={item.storageClassesDesired.join(', ')} />
+ </Grid>
</Grid>;
};
import { GroupClass, GroupResource } from 'models/group';
import { getProperty } from 'store/properties/properties';
import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
+import { CollectionResource } from 'models/collection';
type CssRules = "toolbar" | "button";
const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
- if (menuKind&& resource) {
+ if (menuKind && resource) {
this.props.dispatch<any>(openContextMenu(event, {
name: resource.name,
uuid: resource.uuid,
kind: resource.kind,
menuKind,
description: resource.description,
+ storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
}));
}
this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
} from 'store/resource-type-filters/resource-type-filters';
import { GroupContentsResource } from 'services/groups-service/groups-service';
import { GroupClass, GroupResource } from 'models/group';
+import { CollectionResource } from 'models/collection';
type CssRules = 'root' | "button";
kind: resource.kind,
menuKind,
description: resource.description,
+ storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
}));
}
this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
import { PublicFavoritesState } from 'store/public-favorites/public-favorites-reducer';
import { getResource, ResourcesState } from 'store/resources/resources';
import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { CollectionResource } from 'models/collection';
type CssRules = "toolbar" | "button";
dispatch<any>(openContextMenu(event, {
name: resource.name,
description: resource.description,
+ storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
uuid: resourceUuid,
ownerUuid: '',
kind: ResourceKind.NONE,
Login:
PAM:
Enable: true
+ Volumes:
+ zzzzz-nyw5e-000000000000000:
+ StorageClasses:
+ default: true
+ foo: true
+ zzzzz-nyw5e-000000000000001:
+ StorageClasses:
+ default: true
+ bar: true