Merge branch '17573-edit-storage-classes' into main. Closes #17573
authorLucas Di Pentima <lucas.dipentima@curii.com>
Fri, 16 Jul 2021 19:04:19 +0000 (16:04 -0300)
committerLucas Di Pentima <lucas.dipentima@curii.com>
Fri, 16 Jul 2021 19:04:19 +0000 (16:04 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima@curii.com>

15 files changed:
cypress/integration/collection.spec.js
src/common/config.ts
src/components/checkbox-field/checkbox-field.tsx
src/store/collections/collection-update-actions.ts
src/store/context-menu/context-menu-actions.ts
src/views-components/details-panel/collection-details.tsx
src/views-components/dialog-forms/update-collection-dialog.ts
src/views-components/dialog-update/dialog-collection-update.tsx
src/views-components/form-fields/collection-form-fields.tsx
src/views/collection-content-address-panel/collection-content-address-panel.tsx
src/views/collection-panel/collection-panel.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/public-favorites-panel/public-favorites-panel.tsx
tools/arvados_config.yml

index f3b63218de0901d39472368429bd9c2df51e6072..308ce5f76be13178ccb7d8fe5fa82e7b56496ac7 100644 (file)
@@ -431,6 +431,71 @@ describe('Collection panel tests', function () {
             });
     });
 
+    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)}`;
 
index f3d06840497968eba7388a523d05621038d07ff8..28d4855b47ea7f51c5f702d026fb86edaeb258dd 100644 (file)
@@ -91,6 +91,13 @@ export interface ClusterConfigJSON {
             }
         }
     };
+    Volumes: {
+        [key: string]: {
+            StorageClasses: {
+                [key: string]: boolean;
+            }
+        }
+    };
 }
 
 export class Config {
@@ -130,6 +137,19 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): 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;
@@ -252,6 +272,7 @@ export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): Clust
     Collections: {
         ForwardSlashNameSubstitution: "",
     },
+    Volumes: {},
     ...config
 });
 
index 2b2a8a03e3e92b821239f22ea254777f839f76f2..02a5e9a68ddd36f79dafa8362a9d8090cc4e40a6 100644 (file)
@@ -4,7 +4,14 @@
 
 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
@@ -15,5 +22,47 @@ export const CheckboxField = (props: WrappedFieldProps & { label?: string }) =>
                 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
index 3f3b662fc99b9901c5d90417974859dbda0f9fda..a9077cfb7455db4fd5c4b6a133502e9eb6da72ed 100644 (file)
@@ -19,6 +19,7 @@ export interface CollectionUpdateFormDialogData {
     uuid: string;
     name: string;
     description?: string;
+    storageClassesDesired?: string[];
 }
 
 export const COLLECTION_UPDATE_FORM_NAME = 'collectionUpdateFormName';
@@ -37,6 +38,7 @@ export const updateCollection = (collection: CollectionUpdateFormDialogData) =>
 
         services.collectionService.update(uuid, {
             name: collection.name,
+            storageClassesDesired: collection.storageClassesDesired,
             description: collection.description }
         ).then(updatedCollection => {
             dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
index 038b31e2decef3d6a12cceee199cb596e6aeb241..556d83b4900a3731ee714ab983cd76ef1a99baea 100644 (file)
@@ -39,6 +39,7 @@ export type ContextMenuResource = {
     isEditable?: boolean;
     outputUuid?: string;
     workflowUuid?: string;
+    storageClassesDesired?: string[];
 };
 
 export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
index 0e747fed390eb508262c1bd0da29bd16ebb66dd9..c61b3340c35ee92b02d8c7f2fb0346d56d6c5666 100644 (file)
@@ -87,6 +87,8 @@ const mapDispatchToProps = () =>
                 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,
index 36e5cc390d65207d34dd60fc39f9c4813d823fc0..e5d52f0bd28ca54a390a9c7d71ddcc4c77d28ede 100644 (file)
@@ -2,19 +2,23 @@
 //
 // 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
index c30ceaac9e24527f65d13880395351f0ed2caa11..cce64d27ac8a987449b4714abbb8d6b1e62249bb 100644 (file)
@@ -7,7 +7,11 @@ import { InjectedFormProps } from 'redux-form';
 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>;
 
@@ -22,4 +26,5 @@ export const DialogCollectionUpdate = (props: DialogCollectionProps) =>
 const CollectionEditFields = () => <span>
     <CollectionNameField />
     <CollectionDescriptionField />
+    <CollectionStorageClassesField />
 </span>;
index b882d684f79b0d4fd8a83f1dd0137a7268f95eab..db18f76d8f6ed0de21ec9601db2a6664419720d4 100644 (file)
@@ -13,6 +13,8 @@ import { ProjectTreePickerField, CollectionTreePickerField } from "views-compone
 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[];
@@ -55,3 +57,23 @@ export const CollectionPickerField = (props: PickerIdProp) =>
         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
index 13e131b04a1265b85e13cb51c8ea71f65f357c17..88638085fa736fc891c43aafbe2072e834eed291 100644 (file)
@@ -34,6 +34,9 @@ import {
     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';
 
@@ -110,18 +113,29 @@ export const collectionContentAddressPanelColumns: DataColumns<string> = [
     }
 ];
 
-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,
@@ -145,8 +159,8 @@ interface CollectionContentAddressDataProps {
 }
 
 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
@@ -160,7 +174,7 @@ export const CollectionsContentAddressPanel = withStyles(styles)(
                         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={
index 94a5af33c3d7a6039a52feae5ff82b5a98c42e98..4270cbbdd490199105390a4bed2300e2243d4ede 100644 (file)
@@ -237,13 +237,14 @@ export const CollectionPanel = withStyles(styles)(
             }
 
             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,
                 };
@@ -341,5 +342,9 @@ export const CollectionDetailsAttributes = (props: { item: CollectionResource, t
             <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>;
 };
index 82336b4a64e2d2b469782bcf35634122c17c26aa..404baeb9d1c274ac199dc6ced4fb557963a47e1e 100644 (file)
@@ -39,6 +39,7 @@ import { GroupContentsResource } from 'services/groups-service/groups-service';
 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";
 
@@ -151,7 +152,7 @@ export const FavoritePanel = withStyles(styles)(
 
                 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,
@@ -160,6 +161,7 @@ export const FavoritePanel = withStyles(styles)(
                         kind: resource.kind,
                         menuKind,
                         description: resource.description,
+                        storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
                     }));
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
index ed8a706a79710b0943ccb6a1025d8a6ccfb202d5..672645111743769f0dcec12644277479e43f0c8a 100644 (file)
@@ -45,6 +45,7 @@ import {
 } 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";
 
@@ -185,6 +186,7 @@ export const ProjectPanel = withStyles(styles)(
                         kind: resource.kind,
                         menuKind,
                         description: resource.description,
+                        storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
                     }));
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
index 1b7185deb1a747d661d82078925016fd88dad37a..ee09654a998ec0ea74df07be3c8bb25e28046374 100644 (file)
@@ -37,6 +37,7 @@ import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-fa
 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";
 
@@ -134,6 +135,7 @@ const mapDispatchToProps = (dispatch: Dispatch): PublicFavoritePanelActionProps
             dispatch<any>(openContextMenu(event, {
                 name: resource.name,
                 description: resource.description,
+                storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
                 uuid: resourceUuid,
                 ownerUuid: '',
                 kind: ResourceKind.NONE,
index a287fed4bddb8d0f6d2e76b4fcd8e8ba1d0de267..963da48d8dca385f0fba311de4a0ac09ee860224 100644 (file)
@@ -17,3 +17,12 @@ Clusters:
     Login:
       PAM:
         Enable: true
+    Volumes:
+      zzzzz-nyw5e-000000000000000:
+        StorageClasses:
+          default: true
+          foo: true
+      zzzzz-nyw5e-000000000000001:
+        StorageClasses:
+          default: true
+          bar: true