fileViewersConfigUrl: string;
loginCluster: string;
clusterConfig: ClusterConfigJSON;
+ apiRevision: number;
}
export const buildConfig = (clusterConfigJSON: ClusterConfigJSON): Config => {
config.keepWebServiceUrl = clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
config.loginCluster = clusterConfigJSON.Login.LoginCluster;
config.clusterConfig = clusterConfigJSON;
+ config.apiRevision = 0;
mapRemoteHosts(clusterConfigJSON, config);
return config;
};
+const getApiRevision = async (apiUrl: string) => {
+ try {
+ const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
+ return parseInt(dd.revision, 10) || 0;
+ } catch {
+ console.warn("Unable to get API Revision number, defaulting to zero. Some features may not work properly.");
+ return 0;
+ }
+};
+
export const fetchConfig = () => {
return Axios
.get<WorkbenchConfig>(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime())
if (workbenchConfig.API_HOST === undefined) {
throw new Error(`Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`);
}
- return Axios.get<ClusterConfigJSON>(getClusterConfigURL(workbenchConfig.API_HOST)).then(response => {
+ return Axios.get<ClusterConfigJSON>(getClusterConfigURL(workbenchConfig.API_HOST)).then(async response => {
const clusterConfigJSON = response.data;
- const config = buildConfig(clusterConfigJSON);
+ const apiRevision = await getApiRevision(clusterConfigJSON.Services.Controller.ExternalURL);
+ const config = {...buildConfig(clusterConfigJSON), apiRevision};
const warnLocalConfig = (varName: string) => console.warn(
`A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`);
fileViewersConfigUrl: "",
loginCluster: "",
clusterConfig: mockClusterConfigJSON({}),
+ apiRevision: 0,
...config
});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as _ from "./resource-properties";
+import { omit } from "lodash";
+
+describe("Resource properties lib", () => {
+
+ let properties: any;
+
+ beforeEach(() => {
+ properties = {
+ animal: 'dog',
+ color: ['brown', 'black'],
+ name: ['Toby']
+ }
+ })
+
+ it("should convert a single string value into a list when adding values", () => {
+ expect(
+ _.addProperty(properties, 'animal', 'cat')
+ ).toEqual({
+ ...properties, animal: ['dog', 'cat']
+ });
+ });
+
+ it("should convert a 2 value list into a string when removing values", () => {
+ expect(
+ _.deleteProperty(properties, 'color', 'brown')
+ ).toEqual({
+ ...properties, color: 'black'
+ });
+ });
+
+ it("shouldn't add duplicated key:value items", () => {
+ expect(
+ _.addProperty(properties, 'animal', 'dog')
+ ).toEqual(properties);
+ });
+
+ it("should remove the key when deleting from a one value list", () => {
+ expect(
+ _.deleteProperty(properties, 'name', 'Toby')
+ ).toEqual(omit(properties, 'name'));
+ });
+
+ it("should return the same when deleting non-existant value", () => {
+ expect(
+ _.deleteProperty(properties, 'animal', 'dolphin')
+ ).toEqual(properties);
+ });
+
+ it("should return the same when deleting non-existant key", () => {
+ expect(
+ _.deleteProperty(properties, 'doesntexist', 'something')
+ ).toEqual(properties);
+ });
+});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const deleteProperty = (properties: any, key: string, value: string) => {
+ if (Array.isArray(properties[key])) {
+ properties[key] = properties[key].filter((v: string) => v !== value);
+ if (properties[key].length === 1) {
+ properties[key] = properties[key][0];
+ } else if (properties[key].length === 0) {
+ delete properties[key];
+ }
+ } else if (properties[key] === value) {
+ delete properties[key];
+ }
+ return properties;
+}
+
+export const addProperty = (properties: any, key: string, value: string) => {
+ if (properties[key]) {
+ if (Array.isArray(properties[key])) {
+ properties[key] = [...properties[key], value];
+ } else {
+ properties[key] = [properties[key], value];
+ }
+ // Remove potential duplicate and save as single value if needed
+ properties[key] = Array.from(new Set(properties[key]));
+ if (properties[key].length === 1) {
+ properties[key] = properties[key][0];
+ }
+ } else {
+ properties[key] = value;
+ }
+ return properties;
+}
\ No newline at end of file
loggedIn: boolean;
status: SessionStatus;
active: boolean;
+ apiRevision: number;
}
).toEqual(`["etag","ilike","%etagValue%"]`);
});
+ it("should add 'contains' rule", () => {
+ expect(
+ filters.addContains("properties.someProp", "someValue").getFilters()
+ ).toEqual(`["properties.someProp","contains","someValue"]`);
+ });
+
it("should add 'is_a' rule", () => {
expect(
filters.addIsA("etag", "etagValue").getFilters()
return this.addCondition(field, "ilike", value, "%", "%", resourcePrefix);
}
+ public addContains(field: string, value?: string, resourcePrefix?: string) {
+ return this.addCondition(field, "contains", value, "", "", resourcePrefix);
+ }
+
public addIsA(field: string, value?: string | string[], resourcePrefix?: string) {
return this.addCondition(field, "is_a", value, "", "", resourcePrefix);
}
loggedIn: true,
active: true,
uuid: user ? user.uuid : '',
- status: SessionStatus.VALIDATED
+ status: SessionStatus.VALIDATED,
+ apiRevision: cfg.apiRevision,
} as Session;
const localSessions = this.getSessions().map(s => ({
...s,
loggedIn: false,
active: false,
uuid: '',
- status: SessionStatus.INVALIDATED
+ status: SessionStatus.INVALIDATED,
+ apiRevision: 0,
} as Session;
});
const sessions = [currentSession]
import * as jsSHA from "jssha";
const getClusterConfig = async (origin: string): Promise<Config | null> => {
- // Try the new public config endpoint
+ let configFromDD: Config | undefined;
try {
- const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
- return buildConfig(config);
- } catch { }
-
- // Fall back to discovery document
- try {
- const config = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
- return {
- baseUrl: normalizeURLPath(config.baseUrl),
- keepWebServiceUrl: config.keepWebServiceUrl,
- remoteHosts: config.remoteHosts,
- rootUrl: config.rootUrl,
- uuidPrefix: config.uuidPrefix,
- websocketUrl: config.websocketUrl,
- workbenchUrl: config.workbenchUrl,
- workbench2Url: config.workbench2Url,
+ const dd = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
+ configFromDD = {
+ baseUrl: normalizeURLPath(dd.baseUrl),
+ keepWebServiceUrl: dd.keepWebServiceUrl,
+ remoteHosts: dd.remoteHosts,
+ rootUrl: dd.rootUrl,
+ uuidPrefix: dd.uuidPrefix,
+ websocketUrl: dd.websocketUrl,
+ workbenchUrl: dd.workbenchUrl,
+ workbench2Url: dd.workbench2Url,
loginCluster: "",
vocabularyUrl: "",
fileViewersConfigUrl: "",
- clusterConfig: mockClusterConfigJSON({})
+ clusterConfig: mockClusterConfigJSON({}),
+ apiRevision: parseInt(dd.revision, 10),
};
} catch { }
+ // Try the new public config endpoint
+ try {
+ const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
+ return {...buildConfig(config), apiRevision: configFromDD ? configFromDD.apiRevision : 0};
+ } catch { }
+
+ // Fall back to discovery document
+ if (configFromDD !== undefined) {
+ return configFromDD;
+ }
+
return null;
};
dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
session.loggedIn = false;
- const setupSession = (baseUrl: string, user: User, token: string) => {
+ const setupSession = (baseUrl: string, user: User, token: string, apiRevision: number) => {
session.baseUrl = baseUrl;
session.token = token;
session.email = user.email;
session.uuid = user.uuid;
session.name = getUserFullname(user);
session.loggedIn = true;
+ session.apiRevision = apiRevision;
};
let fail: Error | null = null;
dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
try {
const { user, token } = await validateCluster(config, session.token);
- setupSession(config.baseUrl, user, token);
+ setupSession(config.baseUrl, user, token, config.apiRevision);
} catch (e) {
fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
try {
const { user, token } = await validateCluster(config, activeSession.token);
- setupSession(config.baseUrl, user, token);
+ setupSession(config.baseUrl, user, token, config.apiRevision);
fail = null;
} catch (e2) {
if (e.message === invalidV2Token) {
baseUrl: config.baseUrl,
clusterId: config.uuidPrefix,
remoteHost,
- token
+ token,
+ apiRevision: config.apiRevision,
};
if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
rootUrl: "https://zzzzz.arvadosapi.com",
uuidPrefix: "zzzzz",
remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
+ apiRevision: 12345678,
};
store.dispatch(initAuth(config));
expect(auth).toEqual({
apiToken: "token",
config: {
+ apiRevision: 12345678,
remoteHosts: {
"xc59z": "xc59z.arvadosapi.com",
},
loginCluster: undefined,
remoteHostsConfig: {
"zzzzz": {
+ "apiRevision": 12345678,
"remoteHosts": {
"xc59z": "xc59z.arvadosapi.com",
},
"remoteHost": "https://zzzzz.arvadosapi.com",
"status": 2,
"token": "token",
- "name": "John Doe"
- "uuid": "zzzzz-tpzed-abcefg",
+ "name": "John Doe",
+ "apiRevision": 12345678,
+ "uuid": "zzzzz-tpzed-abcefg",
}, {
"active": false,
"baseUrl": "",
"token": "",
"name": "",
"uuid": "",
+ "apiRevision": 0,
}],
user: {
email: "test@test.com",
import { SnackbarKind } from '~/store/snackbar/snackbar-actions';
import { navigateTo } from '~/store/navigation/navigation-action';
import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
+import { addProperty, deleteProperty } from "~/lib/resource-properties";
export const collectionPanelActions = unionize({
SET_COLLECTION: ofType<CollectionResource>(),
export const createCollectionTag = (data: TagProperty) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const item = getState().collectionPanel.item;
- const uuid = item ? item.uuid : '';
+ if (!item) { return; }
+
+ const properties = Object.assign({}, item.properties);
try {
- if (item) {
- const updatedCollection = await services.collectionService.update(
- uuid, {
- properties: {
- ...JSON.parse(JSON.stringify(item.properties)),
- [data.keyID || data.key]: data.valueID || data.value
- }
- }
- );
- item.properties = updatedCollection.properties;
- dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
- return updatedCollection;
- }
- return;
+ const key = data.keyID || data.key;
+ const value = data.valueID || data.value;
+ const updatedCollection = await services.collectionService.update(
+ item.uuid, {
+ properties: addProperty(properties, key, value)
+ }
+ );
+ dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
+ dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+ return updatedCollection;
} catch (e) {
dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
return;
}
};
-export const deleteCollectionTag = (key: string) =>
+export const deleteCollectionTag = (key: string, value: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const item = getState().collectionPanel.item;
- const uuid = item ? item.uuid : '';
+ if (!item) { return; }
+
+ const properties = Object.assign({}, item.properties);
try {
- if (item) {
- delete item.properties[key];
- const updatedCollection = await services.collectionService.update(
- uuid, {
- properties: {...item.properties}
- }
- );
- dispatch(resourcesActions.SET_RESOURCES([updatedCollection]));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Tag has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
- return updatedCollection;
- }
- return;
+ const updatedCollection = await services.collectionService.update(
+ item.uuid, {
+ properties: deleteProperty(properties, key, value)
+ }
+ );
+ 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 }));
+ return updatedCollection;
} catch (e) {
dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
return;
import { startSubmit, stopSubmit } from 'redux-form';
import { resourcesActions } from '~/store/resources/resources-actions';
import {snackbarActions, SnackbarKind} from '~/store/snackbar/snackbar-actions';
+import { addProperty, deleteProperty } from '~/lib/resource-properties';
export const SLIDE_TIMEOUT = 500;
dispatch<any>(dialogActions.OPEN_DIALOG({ id: PROJECT_PROPERTIES_DIALOG_NAME, data: { } }));
};
-export const deleteProjectProperty = (key: string) =>
+export const deleteProjectProperty = (key: string, value: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const { detailsPanel, resources } = getState();
const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+ if (!project) { return; }
+
+ const properties = Object.assign({}, project.properties);
+
try {
- if (project) {
- delete project.properties[key];
- const updatedProject = await services.projectService.update(project.uuid, { properties: project.properties });
- dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
- }
+ const updatedProject = await services.projectService.update(
+ project.uuid, {
+ properties: deleteProperty(properties, key, value),
+ });
+ dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
} catch (e) {
- dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
- throw new Error('Could not remove property from the project.');
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
}
};
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const { detailsPanel, resources } = getState();
const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+ if (!project) { return; }
+
dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
try {
- if (project) {
- const updatedProject = await services.projectService.update(
- project.uuid, {
- properties: {
- ...JSON.parse(JSON.stringify(project.properties)),
- [data.keyID || data.key]: data.valueID || data.value
- }
- }
- );
- dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
- dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
- dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
- }
- return;
+ const key = data.keyID || data.key;
+ const value = data.valueID || data.value;
+ const properties = Object.assign({}, project.properties);
+ const updatedProject = await services.projectService.update(
+ project.uuid, {
+ properties: addProperty(properties, key, value),
+ }
+ );
+ dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+ dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
} catch (e) {
- dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
- throw new Error('Could not add property to the project.');
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.errors[0], hideDuration: 2000, kind: SnackbarKind.ERROR }));
}
};
export const toggleDetailsPanel = () => (dispatch: Dispatch) => {
inTrash: true,
dateFrom: '2017-08-01',
dateTo: '',
- properties: [{
- key: 'file size',
- value: '101mb'
- }],
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', value: 'Human' },
+ { key: 'Species', value: 'Canine' },
+ ],
saveQuery: false,
queryName: ''
});
- expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:trashed from:2017-08-01 has:"file size":"101mb"');
+ expect(q).toBe('document pdf type:arvados#collection cluster:c97qx is:trashed from:2017-08-01 has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"');
+ });
+
+ it('should add has:"key":"value" expression to query from same property key', () => {
+ const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Canine"';
+ const prevData = {
+ searchValue,
+ type: undefined,
+ cluster: undefined,
+ projectUuid: undefined,
+ inTrash: false,
+ dateFrom: '',
+ dateTo: '',
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', value: 'Canine' },
+ ],
+ saveQuery: false,
+ queryName: ''
+ };
+ const currData = {
+ ...prevData,
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', value: 'Canine' },
+ { key: 'Species', value: 'Human' },
+ ],
+ };
+ const q = getQueryFromAdvancedData(currData, prevData);
+ expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine" has:"Species":"Human"');
+ });
+
+ it('should add has:"keyID":"valueID" expression to query when necessary', () => {
+ const searchValue = 'document pdf has:"file size":"101mb"';
+ const prevData = {
+ searchValue,
+ type: undefined,
+ cluster: undefined,
+ projectUuid: undefined,
+ inTrash: false,
+ dateFrom: '',
+ dateTo: '',
+ properties: [
+ { key: 'file size', value: '101mb' },
+ ],
+ saveQuery: false,
+ queryName: ''
+ };
+ const currData = {
+ ...prevData,
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', keyID: 'IDTAGSPECIES', value: 'Human', valueID: 'IDVALHUMAN'},
+ ],
+ };
+ const q = getQueryFromAdvancedData(currData, prevData);
+ expect(q).toBe('document pdf has:"file size":"101mb" has:"IDTAGSPECIES":"IDVALHUMAN"');
+ });
+
+ it('should remove has:"key":"value" expression from query', () => {
+ const searchValue = 'document pdf has:"file size":"101mb" has:"Species":"Human" has:"Species":"Canine"';
+ const prevData = {
+ searchValue,
+ type: undefined,
+ cluster: undefined,
+ projectUuid: undefined,
+ inTrash: false,
+ dateFrom: '',
+ dateTo: '',
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', value: 'Canine' },
+ { key: 'Species', value: 'Human' },
+ ],
+ saveQuery: false,
+ queryName: ''
+ };
+ const currData = {
+ ...prevData,
+ properties: [
+ { key: 'file size', value: '101mb' },
+ { key: 'Species', value: 'Canine' },
+ ],
+ };
+ const q = getQueryFromAdvancedData(currData, prevData);
+ expect(q).toBe('document pdf has:"file size":"101mb" has:"Species":"Canine"');
});
});
});
const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
const sessions = getSearchSessions(clusterId, getState().auth.sessions);
const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map(session => {
- const filters = queryToFilters(searchValue);
+ const filters = queryToFilters(searchValue, session.apiRevision);
return services.groupsService.contents('', {
filters,
limit,
}
};
-const buildQueryFromKeyMap = (data: any, keyMap: string[][], mode: 'rebuild' | 'reuse') => {
+const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => {
let value = data.searchValue;
const addRem = (field: string, key: string) => {
const v = data[key];
-
+ // Remove previous search expression.
if (data.hasOwnProperty(key)) {
- const pattern = v === false
- ? `${field.replace(':', '\\:\\s*')}\\s*`
- : `${field.replace(':', '\\:\\s*')}\\:\\s*"[\\w|\\#|\\-|\\/]*"\\s*`;
+ let pattern: string;
+ if (v === false) {
+ pattern = `${field.replace(':', '\\:\\s*')}\\s*`;
+ } else if (key.startsWith('prop-')) {
+ // On properties, only remove key:value duplicates, allowing
+ // multiple properties with the same key.
+ const oldValue = key.slice(5).split(':')[1];
+ pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*${oldValue}\\s*`;
+ } else {
+ pattern = `${field.replace(':', '\\:\\s*')}\\:\\s*[\\w|\\#|\\-|\\/]*\\s*`;
+ }
value = value.replace(new RegExp(pattern), '');
}
-
+ // Re-add it with the current search value.
if (v) {
const nv = v === true
? `${field}`
: `${field}:${v}`;
-
- if (mode === 'rebuild') {
- value = value + ' ' + nv;
- } else {
- value = nv + ' ' + value;
- }
+ // Always append to the end to keep user-entered text at the start.
+ value = value + ' ' + nv;
}
};
-
keyMap.forEach(km => addRem(km[0], km[1]));
-
return value;
};
dateFrom: data.dateFrom,
dateTo: data.dateTo,
};
- (data.properties || []).forEach(p => fo[`prop-"${p.keyID || p.key}"`] = `"${p.valueID || p.value}"`);
+ (data.properties || []).forEach(p =>
+ fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"`
+ );
return fo;
};
['to', 'dateTo']
];
_.union(data.properties, prevData ? prevData.properties : [])
- .forEach(p => keyMap.push([`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}"`]));
+ .forEach(p => keyMap.push(
+ [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`]
+ ));
- if (prevData) {
- const obj = getModifiedKeysValues(flatData(data), flatData(prevData));
- value = buildQueryFromKeyMap({
- searchValue: data.searchValue,
- ...obj
- } as SearchBarAdvancedFormData, keyMap, "reuse");
- } else {
- value = buildQueryFromKeyMap(flatData(data), keyMap, "rebuild");
- }
+ const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData):{});
+ value = buildQueryFromKeyMap(
+ {searchValue: data.searchValue, ...modified} as SearchBarAdvancedFormData, keyMap);
value = value.trim();
return value;
return sessions.filter(s => s.loggedIn && (!clusterId || s.clusterId === clusterId));
};
-export const queryToFilters = (query: string) => {
+export const queryToFilters = (query: string, apiRevision: number) => {
const data = getAdvancedDataFromQuery(query);
const filter = new FilterBuilder();
const resourceKind = data.type;
data.properties.forEach(p => {
if (p.value) {
- filter
- .addILike(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
- .addILike(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION);
+ if (apiRevision < 20200212) {
+ filter
+ .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+ .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+ .addEqual(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+ } else {
+ filter
+ .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROJECT)
+ .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.COLLECTION)
+ .addContains(`properties.${p.key}`, p.value, GroupContentsResourcePrefix.PROCESS);
+ }
}
filter.addExists(p.key);
});
return;
}
- const params = getParams(dataExplorer, searchValue);
-
const initial = {
itemsAvailable: 0,
items: [] as GroupContentsResource[],
api.dispatch(setItems(initial));
}
- sessions.map(session =>
+ sessions.map(session => {
+ const params = getParams(dataExplorer, searchValue, session.apiRevision);
this.services.groupsService.contents('', params, session)
.then((response) => {
api.dispatch(updateResources(response.items));
api.dispatch(appendItems(response));
}).catch(() => {
api.dispatch(couldNotFetchSearchResults(session.clusterId));
- })
+ });
+ }
);
}
}
const typeFilters = (columns: DataColumns<string>) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
-export const getParams = (dataExplorer: DataExplorer, query: string) => ({
+const getParams = (dataExplorer: DataExplorer, query: string, apiRevision: number) => ({
...dataExplorerToListParams(dataExplorer),
filters: joinFilters(
- queryToFilters(query),
+ queryToFilters(query, apiRevision),
typeFilters(dataExplorer.columns)
),
order: getOrder(dataExplorer),
import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
import { ArvadosTheme } from '~/common/custom-theme';
import { Dispatch } from 'redux';
-import { PropertyChipComponent } from '../resource-properties-form/property-chip';
+import { getPropertyChip } from '../resource-properties-form/property-chip';
export class ProjectDetails extends DetailsData<ProjectResource> {
getIcon(className?: string) {
</DetailsAttribute>
{
Object.keys(project.properties).map(k =>
- <PropertyChipComponent key={k}
- propKey={k} propValue={project.properties[k]}
- className={classes.tag} />
+ Array.isArray(project.properties[k])
+ ? project.properties[k].map((v: string) =>
+ getPropertyChip(k, v, undefined, classes.tag))
+ : getPropertyChip(k, project.properties[k], undefined, classes.tag)
)
}
</div>
import { ArvadosTheme } from '~/common/custom-theme';
import { ProjectPropertiesForm } from '~/views-components/project-properties-dialog/project-properties-form';
import { getResource } from '~/store/resources/resources';
-import { PropertyChipComponent } from "../resource-properties-form/property-chip";
+import { getPropertyChip } from "../resource-properties-form/property-chip";
type CssRules = 'tag';
}
interface ProjectPropertiesDialogActionProps {
- handleDelete: (key: string) => void;
+ handleDelete: (key: string, value: string) => void;
}
const mapStateToProps = ({ detailsPanel, resources, properties }: RootState): ProjectPropertiesDialogDataProps => ({
});
const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionProps => ({
- handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key)),
+ handleDelete: (key: string, value: string) => () => dispatch<any>(deleteProjectProperty(key, value)),
});
type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
<ProjectPropertiesForm />
{project && project.properties &&
Object.keys(project.properties).map(k =>
- <PropertyChipComponent
- onDelete={() => handleDelete(k)}
- key={k} className={classes.tag}
- propKey={k} propValue={project.properties[k]} />)
+ Array.isArray(project.properties[k])
+ ? project.properties[k].map((v: string) =>
+ getPropertyChip(
+ k, v,
+ handleDelete(k, v),
+ classes.tag))
+ : getPropertyChip(
+ k, project.properties[k],
+ handleDelete(k, project.properties[k]),
+ classes.tag)
+ )
}
</DialogContent>
<DialogActions>
);
}
);
+
+export const getPropertyChip = (k:string, v:string, handleDelete:any, className:string) =>
+ <PropertyChipComponent
+ key={`${k}-${v}`} className={className}
+ onDelete={handleDelete}
+ propKey={k} propValue={v} />;
import { formatPropertyValue } from "~/common/formatters";
import { Vocabulary } from '~/models/vocabulary';
import { connectVocabulary } from '../resource-properties-form/property-field-common';
+import * as _ from 'lodash';
type CssRules = 'label' | 'button';
interface SearchBarAdvancedPropertiesViewActionProps {
setProps: () => void;
- setProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
+ addProp: (propertyValues: PropertyValue, properties: PropertyValue[]) => void;
getAllFields: (propertyValues: PropertyValue[]) => PropertyValue[] | [];
}
setProps: (propertyValues: PropertyValue[]) => {
dispatch<any>(changeAdvancedFormProperty('properties', propertyValues));
},
- setProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+ addProp: (propertyValue: PropertyValue, properties: PropertyValue[]) => {
+ // Remove potential duplicates
+ properties = properties.filter(x => ! _.isEqual(
+ {
+ key: x.keyID || x.key,
+ value: x.valueID || x.value
+ }, {
+ key: propertyValue.keyID || propertyValue.key,
+ value: propertyValue.valueID || propertyValue.value
+ }));
dispatch<any>(changeAdvancedFormProperty(
'properties',
- [...properties.filter(e => e.keyID! !== propertyValue.keyID!), propertyValue]
+ [...properties, propertyValue]
));
dispatch<any>(resetAdvancedFormProperty('key'));
dispatch<any>(resetAdvancedFormProperty('value'));
connectVocabulary,
connect(mapStateToProps, mapDispatchToProps))(
withStyles(styles)(
- ({ classes, fields, propertyValues, setProps, setProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
+ ({ classes, fields, propertyValues, setProps, addProp, getAllFields, vocabulary }: SearchBarAdvancedPropertiesViewProps) =>
<Grid container item xs={12} spacing={16}>
<Grid item xs={2} className={classes.label}>Properties</Grid>
<Grid item xs={4}>
<SearchBarValueField />
</Grid>
<Grid container item xs={2} justify='flex-end' alignItems="center">
- <Button className={classes.button} onClick={() => setProp(propertyValues, getAllFields(fields))}
+ <Button className={classes.button} onClick={() => addProp(propertyValues, getAllFields(fields))}
color="primary"
size='small'
variant="contained"
import { formatFileSize } from "~/common/formatters";
import { openDetailsPanel } from '~/store/details-panel/details-panel-action';
import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { PropertyChipComponent } from '~/views-components/resource-properties-form/property-chip';
+import { getPropertyChip } from '~/views-components/resource-properties-form/property-chip';
import { IllegalNamingWarning } from '~/components/warning/warning';
type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link';
</Grid>
<Grid item xs={12}>
{Object.keys(item.properties).map(k =>
- <PropertyChipComponent
- key={k} className={classes.tag}
- onDelete={this.handleDelete(k)}
- propKey={k} propValue={item.properties[k]} />
+ Array.isArray(item.properties[k])
+ ? item.properties[k].map((v: string) =>
+ getPropertyChip(
+ k, v,
+ this.handleDelete(k, v),
+ classes.tag))
+ : getPropertyChip(
+ k, item.properties[k],
+ this.handleDelete(k, item.properties[k]),
+ classes.tag)
)}
</Grid>
</Grid>
kind: SnackbarKind.SUCCESS
}))
- handleDelete = (key: string) => () => {
- this.props.dispatch<any>(deleteCollectionTag(key));
+ handleDelete = (key: string, value: string) => () => {
+ this.props.dispatch<any>(deleteCollectionTag(key, value));
}
openCollectionDetails = () => {