Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>
--url="https://arvados.org" \
--license="GNU Affero General Public License, version 3.0" \
--description="$(DESCRIPTION)" \
- --config-files="etc/arvados/workbench2/workbench2.example.json" \
- $(WORKSPACE)/build/=$(DEST_DIR)
+ --config-files="etc/arvados/$(APP_NAME)/workbench2.example.json" \
+ $(WORKSPACE)/build/=$(DEST_DIR) \
+ etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json
$(RPM_FILE): build
fpm \
--url="https://arvados.org" \
--license="GNU Affero General Public License, version 3.0" \
--description="$(DESCRIPTION)" \
- --config-files="etc/arvados/workbench2/workbench2.example.json" \
- $(WORKSPACE)/build/=$(DEST_DIR)
+ --config-files="etc/arvados/$(APP_NAME)/workbench2.example.json" \
+ $(WORKSPACE)/build/=$(DEST_DIR) \
+ etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json
copy: $(DEB_FILE) $(RPM_FILE)
for target in $(TARGETS) ; do \
# use FPM to create DEB and RPM
packages: copy
+packages-in-docker: workbench2-build-image
+ docker run --env ci="true" \
+ --env ARVADOS_DIRECTORY=/tmp/arvados \
+ --env APP_NAME=${APP_NAME} \
+ --env ITERATION=${ITERATION} \
+ --env TARGETS="${TARGETS}" \
+ -w="/tmp/workbench2" \
+ -t -v ${WORKSPACE}:/tmp/workbench2 \
+ -v ${ARVADOS_DIRECTORY}:/tmp/arvados workbench2-build:latest \
+ make packages
+
workbench2-build-image:
(cd docker && docker build -t workbench2-build .)
```
{
"API_HOST": "string",
- "VOCABULARY_URL": "string",
"FILE_VIEWERS_CONFIG_URL": "string",
}
```
The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable.
-### VOCABULARY_URL
-Local path, or any URL that allows cross-origin requests. See
-[Vocabulary JSON file example](public/vocabulary-example.json).
-
-To use the URL defined in the Arvados cluster configuration, remove the entire `VOCABULARY_URL` entry from the runtime configuration. Found in `/config.json` by default.
-
## FILE_VIEWERS_CONFIG_URL
Local path, or any URL that allows cross-origin requests. See:
});
// Confirm proper vocabulary labels are displayed on the UI.
cy.get('[data-cy=collection-properties-panel]')
- .should('contain', 'Color')
- .and('contain', 'Magenta');
+ .should('contain', 'Color: Magenta');
// Confirm proper vocabulary IDs were saved on the backend.
cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
.its('body').as('collection')
.then(function () {
expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3');
});
+
+ // Case-insensitive on-blur auto-selection test
+ // Key: Size (IDTAGSIZES) - Value: Small (IDVALSIZES2)
+ cy.get('[data-cy=resource-properties-form]').within(() => {
+ cy.get('[data-cy=property-field-key]').within(() => {
+ cy.get('input').type('sIzE');
+ });
+ cy.get('[data-cy=property-field-value]').within(() => {
+ cy.get('input').type('sMaLL');
+ });
+ // Cannot "type()" TAB on Cypress so let's click another field
+ // to trigger the onBlur event.
+ cy.get('[data-cy=property-field-key]').click();
+ cy.root().submit();
+ });
+ // Confirm proper vocabulary labels are displayed on the UI.
+ cy.get('[data-cy=collection-properties-panel]')
+ .should('contain', 'Size: S');
+ // Confirm proper vocabulary IDs were saved on the backend.
+ cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
+ .its('body').as('collection')
+ .then(function () {
+ expect(this.collection.properties.IDTAGSIZES).to.equal('IDVALSIZES2');
+ });
});
});
cy.getAll('@mySharedWritableProject', '@testTargetCollection')
.then(function ([mySharedWritableProject, testTargetCollection]) {
cy.loginAs(adminUser);
-
+
cy.get('[data-cy=side-panel-tree]').contains('My Favorites').click();
const newProjectName = `New project name ${mySharedWritableProject.name}`;
cy.testEditProjectOrCollection('main', mySharedWritableProject.name, newProjectName, newProjectDescription);
cy.testEditProjectOrCollection('main', testTargetCollection.name, newCollectionName, newCollectionDescription, false);
-
+
cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
cy.get('main').contains(newProjectName).rightclick();
cy.get('[data-cy=side-panel-tree]').contains('Public Favorites').click();
cy.testEditProjectOrCollection('main', newProjectName, mySharedWritableProject.name, 'newProjectDescription');
- cy.testEditProjectOrCollection('main', newCollectionName, testTargetCollection.name, 'newCollectionDescription', false);
+ cy.testEditProjectOrCollection('main', newCollectionName, testTargetCollection.name, 'newCollectionDescription', false);
});
});
});
});
+ it('side panel react to refresh when project data changes', () => {
+ const project = 'writableProject';
+
+ cy.createProject({
+ owningUser: activeUser,
+ targetUser: activeUser,
+ projectName: project,
+ canWrite: true,
+ addToFavorites: false
+ });
+
+ cy.getAll('@writableProject')
+ .then(function ([writableProject]) {
+ cy.loginAs(activeUser);
+
+ cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+
+ cy.get('[data-cy=side-panel-tree]').contains(writableProject.name).should('exist');
+
+ cy.trashGroup(activeUser.token, writableProject.uuid);
+
+ cy.contains('Refresh').click();
+
+ cy.contains(writableProject.name).should('not.exist');
+ });
+ });
})
libsecret-1-0 libsecret-1-dev rpm ruby ruby-dev rubygems build-essential \
libpam0g-dev libgbm1 git && \
apt-get clean
-RUN apt-get -yq --no-install-recommends -t buster-backports install golang-go && \
- apt-get clean
+
+# Get Go 1.16.9
+RUN cd /usr/src && \
+ wget https://golang.org/dl/go1.16.9.linux-amd64.tar.gz && \
+ tar xzf go1.16.9.linux-amd64.tar.gz && \
+ ln -s /usr/src/go/bin/go /usr/local/bin/go-1.16.9 && \
+ ln -s /usr/src/go/bin/gofmt /usr/local/bin/gofmt-1.16.9 && \
+ ln -s /usr/local/bin/go-1.16.9 /usr/local/bin/go && \
+ ln -s /usr/local/bin/gofmt-1.16.9 /usr/local/bin/gofmt
+
RUN gem install --no-ri --no-rdoc fpm
RUN git clone https://git.arvados.org/arvados.git && cd arvados && \
go mod download && \
};
Workbench: {
ArvadosDocsite: string;
- VocabularyURL: string;
FileViewersConfigURL: string;
WelcomePageHTML: string;
InactivePageHTML: string;
}
config.fileViewersConfigUrl = fileViewerConfigUrl;
- let vocabularyUrl;
if (workbenchConfig.VOCABULARY_URL !== undefined) {
- warnLocalConfig("VOCABULARY_URL");
- vocabularyUrl = workbenchConfig.VOCABULARY_URL;
+ console.warn(`A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.`)
}
- else {
- vocabularyUrl = config.clusterConfig.Workbench.VocabularyURL || "/vocabulary-example.json";
- }
- config.vocabularyUrl = vocabularyUrl;
+ config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST);
return { config, apiHost: workbenchConfig.API_HOST };
});
},
Workbench: {
ArvadosDocsite: "",
- VocabularyURL: "",
FileViewersConfigURL: "",
WelcomePageHTML: "",
InactivePageHTML: "",
export const ARVADOS_API_PATH = "arvados/v1";
export const CLUSTER_CONFIG_PATH = "arvados/v1/config";
+export const VOCABULARY_PATH = "arvados/v1/vocabulary";
export const DISCOVERY_DOC_PATH = "discovery/v1/apis/arvados/v1/rest";
-export const getClusterConfigURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${(new Date()).getTime()}`;
+export const getClusterConfigURL = (apiHost: string) => `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${(new Date()).getTime()}`;
+export const getVocabularyURL = (apiHost: string) => `https://${apiHost}/${VOCABULARY_PATH}?nocache=${(new Date()).getTime()}`;
</Grid>;
}
- renderChip = (value: Value, index: number) =>
- <Grid item key={index}>
- <this.chip {...{ value }} />
+ renderChip = (value: Value, index: number) => {
+ const { deletable, getLabel } = this.props;
+ return <Grid item key={index}>
+ <Chip onDelete={deletable ? this.deleteValue(value) : undefined}
+ label={getLabel !== undefined ? getLabel(value) : value} />
</Grid>
+ }
type = 'chip';
interface DraggableChipProps<Value> {
value: Value;
-}
\ No newline at end of file
+}
},
});
-export const RefreshButton = ({ history, classes }: RouteComponentProps & WithStyles<CssRules>) =>
+interface RefreshButtonProps {
+ onClick?: () => void;
+}
+
+export const RefreshButton = ({ history, classes, onClick }: RouteComponentProps & WithStyles<CssRules> & RefreshButtonProps) =>
<Button
color="primary"
size="small"
variant="contained"
onClick={() => {
history.replace(window.location.pathname);
+ if (onClick) {
+ onClick();
+ }
}}
className={classNames(classes.buttonRight, classes.button)}>
<ReRunProcessIcon />
(tagKeyID && vocabulary.tags[tagKeyID] && vocabulary.tags[tagKeyID].values)
? Object.keys(vocabulary.tags[tagKeyID].values!).find(
k => vocabulary.tags[tagKeyID].values![k].labels.find(
- l => l.label === tagValueLabel) !== undefined) || ''
+ l => l.label.toLowerCase() === tagValueLabel.toLowerCase()) !== undefined) || ''
: '';
export const getTagValueLabel = (tagKeyID:string, tagValueID:string, vocabulary: Vocabulary) =>
export const getTagKeyID = (tagKeyLabel:string, vocabulary: Vocabulary) =>
Object.keys(vocabulary.tags).find(
k => vocabulary.tags[k].labels.find(
- l => l.label === tagKeyLabel) !== undefined
+ l => l.label.toLowerCase() === tagKeyLabel.toLowerCase()) !== undefined
) || '';
export const getTagKeyLabel = (tagKeyID:string, vocabulary: Vocabulary) =>
//
// SPDX-License-Identifier: AGPL-3.0
-import { AxiosInstance } from 'axios';
-import { WebDAV } from 'common/webdav';
-import { ApiActions } from '../api/api-actions';
+import axios, { AxiosInstance } from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { CollectionResource } from 'models/collection';
import { AuthService } from '../auth-service/auth-service';
import { CollectionService } from './collection-service';
describe('collection-service', () => {
let collectionService: CollectionService;
- let serverApi;
+ let serverApi: AxiosInstance;
+ let axiosMock: MockAdapter;
let webdavClient: any;
let authService;
let actions;
beforeEach(() => {
- serverApi = {} as AxiosInstance;
+ serverApi = axios.create();
+ axiosMock = new MockAdapter(serverApi);
webdavClient = {
delete: jest.fn(),
} as any;
authService = {} as AuthService;
- actions = {} as ApiActions;
+ actions = {
+ progressFn: jest.fn(),
+ } as any;
collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
collectionService.update = jest.fn();
});
+ describe('update', () => {
+ it('should call put selecting updated fields + others', async () => {
+ serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+ const data: Partial<CollectionResource> = {
+ name: 'foo',
+ };
+ const expected = {
+ collection: {
+ ...data,
+ preserve_version: true,
+ },
+ select: ['uuid', 'name', 'version', 'modified_at'],
+ }
+ collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
+ await collectionService.update('uuid', data);
+ expect(serverApi.put).toHaveBeenCalledWith('/collections/uuid', expected);
+ });
+ });
+
describe('deleteFiles', () => {
it('should remove no files', async () => {
// given
}
update(uuid: string, data: Partial<CollectionResource>) {
- return super.update(uuid, { ...data, preserveVersion: true });
+ const select = [...Object.keys(data), 'version', 'modifiedAt'];
+ return super.update(uuid, { ...data, preserveVersion: true }, select);
}
async files(uuid: string) {
return super.create(payload);
}
- update(uuid: string, data: Partial<T>) {
+ update(uuid: string, data: Partial<T>, select?: string[]) {
let payload: any;
if (data !== undefined) {
this.readOnlyFields.forEach( field => delete data[field] );
payload = {
[this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
};
+ if (select !== undefined && select.length > 0) {
+ payload.select = ['uuid', ...select.map(field => snakeCase(field))];
+ };
}
return super.update(uuid, payload);
}
private url: string
) { }
- getVocabulary() {
- return Axios
- .get<Vocabulary>(this.url)
- .then(response => response.data);
+ async getVocabulary() {
+ const response = await Axios
+ .get<Vocabulary>(this.url);
+ return response.data;
}
}
import { navigateTo } from 'store/navigation/navigation-action';
import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
import { addProperty, deleteProperty } from "lib/resource-properties";
+import { getResource } from "store/resources/resources";
export const collectionPanelActions = unionize({
SET_COLLECTION: ofType<CollectionResource>(),
dispatch(resourcesActions.SET_RESOURCES([collection]));
if (collection.fileCount <= COLLECTION_PANEL_LOAD_FILES_THRESHOLD &&
!getState().collectionPanel.loadBigCollections) {
- // dispatch<any>(loadCollectionFiles(collection.uuid));
}
return collection;
};
const properties = Object.assign({}, item.properties);
const key = data.keyID || data.key;
const value = data.valueID || data.value;
+ const cachedCollection = getResource<CollectionResource>(item.uuid)(getState().resources);
services.collectionService.update(
item.uuid, {
properties: addProperty(properties, key, value)
}
).then(updatedCollection => {
+ updatedCollection = {...cachedCollection, ...updatedCollection};
dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
dispatch(snackbarActions.OPEN_SNACKBAR({
if (!item) { return; }
const properties = Object.assign({}, item.properties);
+ const cachedCollection = getResource<CollectionResource>(item.uuid)(getState().resources);
services.collectionService.update(
item.uuid, {
properties: deleteProperty(properties, key, value)
}
).then(updatedCollection => {
+ updatedCollection = {...cachedCollection, ...updatedCollection};
dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
import { updateResources } from "../resources/resources-actions";
import { loadDetailsPanel } from "../details-panel/details-panel-action";
+import { getResource } from "store/resources/resources";
export interface CollectionUpdateFormDialogData {
uuid: string;
dispatch(startSubmit(COLLECTION_UPDATE_FORM_NAME));
dispatch(progressIndicatorActions.START_WORKING(COLLECTION_UPDATE_FORM_NAME));
+ const cachedCollection = getResource<CollectionResource>(collection.uuid)(getState().resources);
services.collectionService.update(uuid, {
name: collection.name,
storageClassesDesired: collection.storageClassesDesired,
description: collection.description }
).then(updatedCollection => {
+ updatedCollection = {...cachedCollection, ...updatedCollection};
dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: updatedCollection as CollectionResource }));
dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPDATE_FORM_NAME));
export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
const vocabulary = await vocabularyService.getVocabulary();
-
dispatch(propertiesActions.SET_PROPERTY({
key: VOCABULARY_PROPERTY_NAME,
value: isVocabulary(vocabulary)
import * as Routes from 'routes/routes';
import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
import RefreshButton from "components/refresh-button/refresh-button";
+import { reloadProjectMatchingUuid } from "store/workbench/workbench-actions";
+import { loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
type CssRules = "infoTooltip";
export const MainContentBar =
connect((state: RootState) => ({
- buttonVisible: isButtonVisible(state)
- }), {
+ buttonVisible: isButtonVisible(state),
+ projectUuid: state.detailsPanel.resourceUuid,
+ }), (dispatch) => ({
onDetailsPanelToggle: toggleDetailsPanel,
- })(
+ onRefreshButtonClick: (id) => {
+ dispatch<any>(loadSidePanelTreeProjects(id));
+ dispatch<any>(reloadProjectMatchingUuid([id]));
+ }
+ }))(
withStyles(styles)(
(props: MainContentBarProps & WithStyles<CssRules> & any) =>
<Toolbar>
<Breadcrumbs />
</Grid>
<Grid item>
- <RefreshButton />
+ <RefreshButton onClick={() => {
+ props.onRefreshButtonClick(props.projectUuid);
+ }} />
</Grid>
<Grid item>
{props.buttonVisible && <Tooltip title="Additional Info">
import { WrappedFieldProps, Field, FormName, reset, change, WrappedFieldInputProps, WrappedFieldMetaProps } from 'redux-form';
import { memoize } from 'lodash';
import { Autocomplete } from 'components/autocomplete/autocomplete';
-import { Vocabulary, getTags, getTagKeyID } from 'models/vocabulary';
+import { Vocabulary, getTags, getTagKeyID, getTagKeyLabel } from 'models/vocabulary';
import {
handleSelect,
handleBlur,
label='Key'
suggestions={getSuggestions(props.input.value, vocabulary)}
onSelect={handleSelect(PROPERTY_KEY_FIELD_ID, data.form, props.input, props.meta)}
- onBlur={handleBlur(PROPERTY_KEY_FIELD_ID, data.form, props.meta, props.input, getTagKeyID(props.input.value, vocabulary))}
+ onBlur={() => {
+ // Case-insensitive search for the key in the vocabulary
+ const foundKeyID = getTagKeyID(props.input.value, vocabulary);
+ if (foundKeyID !== '') {
+ props.input.value = getTagKeyLabel(foundKeyID, vocabulary);
+ }
+ handleBlur(PROPERTY_KEY_FIELD_ID, data.form, props.meta, props.input, foundKeyID)();
+ }}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.currentTarget.value;
handleChange(data.form, props.input, props.meta, newValue);
import { WrappedFieldProps, Field, formValues, FormName, WrappedFieldInputProps, WrappedFieldMetaProps, change } from 'redux-form';
import { compose } from 'redux';
import { Autocomplete } from 'components/autocomplete/autocomplete';
-import { Vocabulary, isStrictTag, getTagValues, getTagValueID } from 'models/vocabulary';
+import { Vocabulary, isStrictTag, getTagValues, getTagValueID, getTagValueLabel } from 'models/vocabulary';
import { PROPERTY_KEY_FIELD_ID, PROPERTY_KEY_FIELD_NAME } from 'views-components/resource-properties-form/property-key-field';
import {
handleSelect,
disabled={props.disabled}
suggestions={getSuggestions(props.input.value, propertyKeyId, vocabulary)}
onSelect={handleSelect(PROPERTY_VALUE_FIELD_ID, data.form, props.input, props.meta)}
- onBlur={handleBlur(PROPERTY_VALUE_FIELD_ID, data.form, props.meta, props.input, getTagValueID(propertyKeyId, props.input.value, vocabulary))}
+ onBlur={() => {
+ // Case-insensitive search for the value in the vocabulary
+ const foundValueID = getTagValueID(propertyKeyId, props.input.value, vocabulary);
+ if (foundValueID !== '') {
+ props.input.value = getTagValueLabel(propertyKeyId, foundValueID, vocabulary);
+ }
+ handleBlur(PROPERTY_VALUE_FIELD_ID, data.form, props.meta, props.input, foundValueID)();
+ }}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.currentTarget.value;
const tagValueID = getTagValueID(propertyKeyId, newValue, vocabulary);
isWritable = true;
} else {
const itemOwner = getResource<GroupResource | UserResource>(item.ownerUuid)(state.resources);
- if (itemOwner) {
+ if (itemOwner && itemOwner.writableBy) {
isWritable = itemOwner.writableBy.indexOf(currentUserUUID || '') >= 0;
}
}
SystemRootToken: systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy
API:
RequestTimeout: 30s
+ VocabularyPath: ""
TLS:
Insecure: true
Collections:
ARVADOS_LOG=${ARVADOS_DIR}/arvados.log
ARVADOS_CONF=${WB2_DIR}/tools/arvados_config.yml
+VOCABULARY_CONF=${WB2_DIR}/tools/example-vocabulary.json
if [ ! -f "${WB2_DIR}/src/index.tsx" ]; then
echo "ERROR: '${WB2_DIR}' isn't workbench2's directory"
~/go/bin/arvados-server install -type test || exit 1
echo "Launching arvados in test mode..."
+VOC_DIR=$(mktemp -d | cut -d \/ -f3) # Removes the /tmp/ part
+cp ${VOCABULARY_CONF} /tmp/${VOC_DIR}/voc.json
+sed -i "s/VocabularyPath: \".*\"/VocabularyPath: \"\/tmp\/${VOC_DIR}\/voc.json\"/" ${ARVADOS_CONF}
coproc arvboot (~/go/bin/arvados-server boot \
-type test \
-config ${ARVADOS_CONF} \