Skip to content

Commit

Permalink
Fix #10782 Support for Tags (#10796)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Lorenzo Natali <[email protected]>
  • Loading branch information
allyoucanmap and offtherailz authored Feb 11, 2025
1 parent 20c08cf commit 54cbd70
Show file tree
Hide file tree
Showing 58 changed files with 2,383 additions and 228 deletions.
7 changes: 5 additions & 2 deletions docs/developer-guide/mapstore-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,24 @@ This is a list of things to check if you want to update from a previous version

## Migration from 2024.02.00 to 2025.01.00

### Add Favorite plugin to localConfig.json
### Add TagsManager and Favorite plugins to localConfig.json

The new Favorite plugin should be added inside the plugins `maps` section of the `localConfig.json` to visualize the button on the resource cards
The new TagsManager and Favorite plugin should be added inside the plugins `maps` section of the `localConfig.json` to visualize a new menu item in the admin menu and to to visualize the button on the resource cards

```diff
{
"plugins": {
...,
"maps": [
...,
+ { "name": "TagsManager" },
+ { "name": "Favorites" }
],
...
}
}
```

## Migration from 2024.01.02 to 2024.02.00

### NodeJS and NPM update
Expand Down
69 changes: 69 additions & 0 deletions web/client/api/GeoStoreDAO.js
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,75 @@ const Api = {
}
},
errorParser,
/**
* get the available tags
* @param {string} textSearch search text query
* @param {object} options additional axios options
*/
getTags: (textSearch, options = {}) => {
const url = '/resources/tag';
return axios.get(url, Api.addBaseUrl(parseOptions({
...options,
params: {
...options?.params,
...(textSearch && { nameLike: textSearch })
}
}))).then((response) => response.data);
},
/**
* update/create a tag
* @param {object} tag a tag object { id, name, description, color } (it will create a new tag if id is undefined)
* @param {object} options additional axios options
*/
updateTag: (tag = {}, options = {}) => {
const url = `/resources/tag${tag.id ? `/${tag.id}` : ''}`;
return axios[tag.id ? 'put' : 'post'](
url,
[
'<Tag>',
`<name><![CDATA[${tag.name}]]></name>`,
`<description><![CDATA[${tag.description}]]></description>`,
`<color>${tag.color}</color>`,
'</Tag>'
].join(''),
Api.addBaseUrl(
parseOptions({
...options,
headers: {
'Content-Type': "application/xml"
}
})
)).then((response) => response.data);
},
/**
* get the available tags
* @param {string} tagId tag identifier
* @param {object} options additional axios options
*/
deleteTag: (tagId, options = {}) => {
const url = `/resources/tag/${tagId}`;
return axios.delete(url, Api.addBaseUrl(parseOptions(options))).then((response) => response.data);
},
/**
* link a tag to a resource
* @param {string} tagId tag identifier
* @param {string} resourceId resource identifier
* @param {object} options additional axios options
*/
linkTagToResource: (tagId, resourceId, options) => {
const url = `/resources/tag/${tagId}/resource/${resourceId}`;
return axios.post(url, undefined, Api.addBaseUrl(parseOptions(options))).then((response) => response.data);
},
/**
* unlink a tag from a resource
* @param {string} tagId tag identifier
* @param {string} resourceId resource identifier
* @param {object} options additional axios options
*/
unlinkTagFromResource: (tagId, resourceId, options) => {
const url = `/resources/tag/${tagId}/resource/${resourceId}`;
return axios.delete(url, Api.addBaseUrl(parseOptions(options))).then((response) => response.data);
},
/**
* add a resource to user favorites
* @param {string} userId user identifier
Expand Down
81 changes: 81 additions & 0 deletions web/client/api/__tests__/GeoStoreDAO-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,87 @@ describe('Test correctness of the GeoStore APIs', () => {
done(e);
});
});
it('getTags', (done) => {
mockAxios.onGet().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag');
expect(data.params).toEqual({ nameLike: '%Search%' });
} catch (e) {
done(e);
}
done();
return [200];
});
API.getTags('%Search%');
});
it('updateTag', (done) => {
mockAxios.onPut().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag/1');
expect(data.data).toBe('<Tag><name><![CDATA[Name]]></name><description><![CDATA[Description]]></description><color>#ff0000</color></Tag>');
} catch (e) {
done(e);
}
done();
return [200];
});
API.updateTag({ id: '1', name: 'Name', description: 'Description', color: '#ff0000' });
});
it('updateTag (create)', (done) => {
mockAxios.onPost().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag');
expect(data.data).toBe('<Tag><name><![CDATA[Name]]></name><description><![CDATA[Description]]></description><color>#ff0000</color></Tag>');
} catch (e) {
done(e);
}
done();
return [200];
});
API.updateTag({ name: 'Name', description: 'Description', color: '#ff0000' });
});
it('deleteTag', (done) => {
mockAxios.onDelete().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag/1');
} catch (e) {
done(e);
}
done();
return [200];
});
API.deleteTag('1');
});
it('linkTagToResource', (done) => {
mockAxios.onPost().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag/1/resource/2');
} catch (e) {
done(e);
}
done();
return [200];
});
API.linkTagToResource('1', '2');
});
it('unlinkTagFromResource', (done) => {
mockAxios.onDelete().reply((data) => {
try {
expect(data.baseURL).toBe('/rest/geostore/');
expect(data.url).toBe('/resources/tag/1/resource/2');
} catch (e) {
done(e);
}
done();
return [200];
});
API.unlinkTagFromResource('1', '2');
});

it('addFavoriteResource', (done) => {
mockAxios.onPost().reply((data) => {
Expand Down
6 changes: 5 additions & 1 deletion web/client/configs/localConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@
]
}
},
{ "name": "TagsManager"},
{ "name": "Favorites" },
{
"name": "ResourcesFiltersForm",
Expand All @@ -801,7 +802,10 @@
"name": "DeleteResource"
},
{
"name": "ResourceDetails"
"name": "ResourceDetails",
"cfg": {
"enableFilters": true
}
},
{
"name": "Share",
Expand Down
89 changes: 89 additions & 0 deletions web/client/observables/__tests__/geostore-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,93 @@ describe('geostore observables for resources management', () => {
e => expect(true).toBe(false, e)
);
});
it('getResource with includeTags set to false', done => {

const ID = 7;

const DummyAPI = {
getShortResource: testAndResolve(
id => expect(id).toBe(ID),
{
ShortResource: {
tagList: {
Tag: {
id: '1',
name: 'Tag',
description: 'description',
color: '#ff0000'
}
}
}
}
),
getResourceAttributes: testAndResolve(
id => expect(id).toBe(ID),
[]
),
getData: testAndResolve(
(id) => {
expect(id).toBe(ID);
},
{}
)
};
getResource(ID, { includeTags: true }, DummyAPI)
.subscribe(
(res) => {
try {
expect(res).toEqual(
{
attributes: {},
data: {},
permissions: undefined,
tags: [{
id: '1',
name: 'Tag',
description: 'description',
color: '#ff0000'
}]
}
);
done();
} catch (e) {
done(e);
}
},
e => expect(true).toBe(false, e)
);
});
it('updateResource with tags', done => {
const ID = 10;
const testResource = {
id: ID,
tags: [{ tag: { id: '1' }, action: 'link'}, { tag: { id: '2' }, action: 'unlink'}]
};
const DummyAPI = {
putResourceMetadataAndAttributes: testAndResolve(
(id) => {
expect(id).toBe(ID);
},
{}
),
linkTagToResource: testAndResolve(
(tagId, resourceId) => {
expect(tagId).toBe('1');
expect(resourceId).toBe(ID);
},
{}
),
unlinkTagFromResource: testAndResolve(
(tagId, resourceId) => {
expect(tagId).toBe('2');
expect(resourceId).toBe(ID);
},
{}
)
};
updateResource(testResource, DummyAPI).subscribe(
() => done(),
e => done(e)
);
});
});
30 changes: 22 additions & 8 deletions web/client/observables/geostore.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { Observable } from 'rxjs';
import uuid from 'uuid/v1';
import { includes, isNil, omit, isArray, isObject, get, find } from 'lodash';
import { includes, isNil, omit, isArray, isObject, get, find, castArray } from 'lodash';
import GeoStoreDAO from '../api/GeoStoreDAO';

const createLinkedResourceURL = (id, tail = "") => `rest/geostore/data/${id}${tail}`;
Expand Down Expand Up @@ -156,21 +156,23 @@ const updateOtherLinkedResourcesPermissions = (id, linkedResources, permission,
* @param {boolean} params.includeAttributes if true, resource will contain resource attributes
* @param {boolean} params.withData if true, resource will contain resource data
* @param {boolean} params.withPermissions if true, resource will contain resource permission
* @param {boolean} params.includeTags if true, resource will contain resource tags (default true)
* @param {object} API the API to use, default GeoStoreDAO
* @return an observable that emits the resource
*/
export const getResource = (id, { includeAttributes = true, withData = true, withPermissions = false, baseURL } = {}, API = GeoStoreDAO) =>
export const getResource = (id, { includeAttributes = true, includeTags = true, withData = true, withPermissions = false, baseURL } = {}, API = GeoStoreDAO) =>
Observable.forkJoin([
Observable.defer(() => API.getShortResource(id)).pluck("ShortResource"),
Observable.defer(() => API.getShortResource(id, includeTags ? { params: { includeTags } } : undefined)).pluck("ShortResource"),
Observable.defer(() => includeAttributes
? API.getResourceAttributes(id)
// when includeAttributes is false we should return an empty array
// to keep the order of response in the .map argument
: new Promise(resolve => resolve([]))),
...(withData ? [Observable.defer(() =>API.getData(id, { baseURL }))] : [Promise.resolve(undefined)]),
...(withPermissions ? [Observable.defer( () => API.getResourcePermissions(id, {}, true))] : [Promise.resolve(undefined)])
]).map(([resource, attributes, data, permissions]) => ({
]).map(([{ tagList, ...resource } = {}, attributes, data, permissions]) => ({
...resource,
...(tagList && { tags: castArray(tagList?.Tag || []) }),
attributes: (attributes || []).reduce((acc, curr) => ({
...acc,
[curr.name]: curr.value
Expand Down Expand Up @@ -292,17 +294,18 @@ export const createCategory = (category, API = GeoStoreDAO) =>

/**
* Updates a resource setting up permission and linked resources
* @param {resource} param0 the resource to update (must contain the id)
* @param {object} resource the resource to update (must contain the id)
* @param {object[]} tags array of tag actions, action can be 'link' or 'unlink', expected structure [{ tag: { id }, action },]
* @param {object} API the API to use
* @return an observable that emits the id of the updated resource
*/

export const updateResource = ({ id, data, permission, metadata, linkedResources = {} } = {}, API = GeoStoreDAO) => {
export const updateResource = ({ id, data, permission, metadata, linkedResources = {}, tags } = {}, API = GeoStoreDAO) => {
const linkedResourcesKeys = Object.keys(linkedResources);

// update metadata
return Observable.forkJoin([
// update data and permissions after data updated
// update data and and permissions after data updated
Observable.defer(
() => API.putResourceMetadataAndAttributes(id, metadata)
).switchMap(res =>
Expand All @@ -321,7 +324,18 @@ export const updateResource = ({ id, data, permission, metadata, linkedResources
) : Observable.of([]))
.switchMap(() => permission ?
Observable.defer(() => updateOtherLinkedResourcesPermissions(id, linkedResources, permission, API)) :
Observable.of(-1))
Observable.of(-1)),

// update tags
Observable
.defer(() => Promise.all(
(tags || [])
.map(({ tag, action }) => action === 'link'
? API.linkTagToResource(tag.id, id)
: API.unlinkTagFromResource(tag.id, id)
)
))
.switchMap(() => Observable.of(-1))
]).map(() => id);
};

Expand Down
Loading

0 comments on commit 54cbd70

Please sign in to comment.