Skip to content
Snippets Groups Projects
Commit c3e8b48e authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(plugins): data overlays (MIN-222)

parent b61872e4
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!152feat(plugins): data overlays (MIN-222)
Showing
with 625 additions and 6 deletions
......@@ -18,6 +18,22 @@
- **Project does not exist**: This error occurs when the project data is not available.
- **Project ID does not exist**: This error occurs when the project ID is not available.
## Overlay Errors
- **Overlay name is not provided**: This error occurs when the name of the overlay is missing or not provided.
- **Failed to read file**: This error occurs when there is an issue reading the content of a file. Check if it's text file.
- **Invalid type of fileContent**: This error occurs when the fileContent parameter is of an invalid type.
- **Overlay with provided id does not exist**: This error occurs when the provided overlay id does not correspond to any existing overlay.
- **Overlay with provided id is not active**: This error occurs when the provided overlay id corresponds to an overlay that is not currently active.
- **Overlay with provided id is already active**: This error occurs when the provided overlay id corresponds to an overlay that is already active.
## Zoom errors
- **Provided zoom value exeeds max zoom of ...**: This error occurs when `zoom` param of `setZoom` exeeds max zoom value of the selected map
......
### Overlays
#### Get list of available data overlays
To get list of available data overlays, plugins can use the `getDataOverlays` method defined in `window.minerva.overlays.data`. This method returns array with all overlays.
##### Example of getDataOverlays usage:
```javascript
window.minerva.overlays.data.getDataOverlays();
```
#### Get list of visible data overlays
To get list of visible data overlays, plugins can use the `getVisibleDataOverlays` method defined in `window.minerva.overlays.data`. This method returns array with all visible data overlays.
##### Example of getVisibleDataOverlays usage:
```javascript
window.minerva.overlays.data.getVisibleDataOverlays();
```
#### Show an overlay
To show an overlay, plugins can use the `showDataOverlay` method defined in `window.minerva.overlays`. This method takes following arguments:
- overlayId - the ID of the overlay that the plugin wants to show.
- setBackgroundEmpty (optional) - whether `showDataOverlay` should set the background to empty if available when it shows overlay. Its value should be a boolean type.
##### Example of showDataOverlay usage:
```javascript
window.minerva.overlays.showDataOverlay(109);
window.minerva.overlays.showDataOverlay(112, true);
```
#### Hide an overlay
To hide an overlay, plugins can use the `hideDataOverlay` method defined in `window.minerva.overlays`. This method takes one argument: the ID of the overlay that the plugin wants to hide.
##### Example of showDataOverlay usage:
```javascript
window.minerva.overlays.hideDataOverlay(109);
```
#### Add an overlay
To add an overlay, plugins can use the `addDataOverlay` method defined in `window.minerva.overlays`. This method takes one argument: the object with the following properties:
- name (string): The name of the overlay.
- description (optional string): A description of the overlay.
- filename (optional string): The filename of the overlay data.
- fileContent (string or text File): The content of the overlay data.
- type (optional string): The type of overlay data.
##### Example of addDataOverlay usage:
```javascript
window.minerva.overlays.addDataOverlay({
name: 'Plugin Test',
fileContent: 'plugin test content',
});
```
#### Remove an overlay
To remove an overlay, plugins can use the `removeDataOverlay` method defined in `window.minerva.overlays`. This method takes one argument: the ID of the overlay that the plugin wants to remove.
##### Example of removeDataOverlay usage:
```javascript
window.minerva.overlays.removeDataOverlay(129);
```
......@@ -46,3 +46,12 @@ To get organism identifier associated with the project, plugins can use the `get
```javascript
window.minerva.project.data.getOrganism();
```
**Get Api Urls:**
To get Api urls associated with the project, plugins can use the `getApiUrls` method defined in `window.minerva.project.data` object.
##### Example usage of getApiUrls method:
```javascript
window.minerva.project.data.getApiUrls();
```
......@@ -17,6 +17,13 @@ import { getName } from '@/services/pluginsManager/project/data/getName';
import { getOrganism } from '@/services/pluginsManager/project/data/getOrganism';
import { getProjectId } from '@/services/pluginsManager/project/data/getProjectId';
import { getVersion } from '@/services/pluginsManager/project/data/getVersion';
import { getDataOverlays } from '@/services/pluginsManager/map/overlays/getDataOverlays';
import { getVisibleDataOverlays } from '@/services/pluginsManager/map/overlays/getVisibleDataOverlays';
import { showDataOverlay } from '@/services/pluginsManager/map/overlays/showDataOverlay';
import { hideDataOverlay } from '@/services/pluginsManager/map/overlays/hideDataOverlay';
import { removeDataOverlay } from '@/services/pluginsManager/map/overlays/removeDataOverlay';
import { addDataOverlay } from '@/services/pluginsManager/map/overlays/addDataOverlay';
import { getApiUrls } from '@/services/pluginsManager/project/data/getApiUrls';
type Plugin = {
pluginName: string;
......@@ -64,6 +71,16 @@ declare global {
selectOverviewImage: typeof selectOverviewImage;
showOverviewImageModal: typeof showOverviewImageModal;
};
overlays: {
data: {
getDataOverlays: typeof getDataOverlays;
getVisibleDataOverlays: typeof getVisibleDataOverlays;
};
showDataOverlay: typeof showDataOverlay;
hideDataOverlay: typeof hideDataOverlay;
removeDataOverlay: typeof removeDataOverlay;
addDataOverlay: typeof addDataOverlay;
};
project: {
data: {
getProjectId: typeof getProjectId;
......@@ -71,6 +88,7 @@ declare global {
getVersion: typeof getVersion;
getDisease: typeof getDisease;
getOrganism: typeof getOrganism;
getApiUrls: typeof getApiUrls;
};
};
};
......
......@@ -3,7 +3,6 @@ import { emptyBackgroundIdSelector } from '@/redux/backgrounds/background.select
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { setMapBackground } from '@/redux/map/map.slice';
import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus';
type UseEmptyBackgroundReturn = {
setBackgroundtoEmptyIfAvailable: () => void;
......@@ -16,8 +15,6 @@ export const useEmptyBackground = (): UseEmptyBackgroundReturn => {
const setBackgroundtoEmptyIfAvailable = useCallback(() => {
if (emptyBackgroundId) {
dispatch(setMapBackground(emptyBackgroundId));
PluginsEventBus.dispatchEvent('onBackgroundOverlayChange', emptyBackgroundId);
}
}, [dispatch, emptyBackgroundId]);
......
......@@ -10,7 +10,6 @@ import { Icon } from '@/shared/Icon';
import { MapBackground } from '@/types/models';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { setMapBackground } from '@/redux/map/map.slice';
import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus';
const DEFAULT_TOGGLE_BUTTON_TEXT = 'Background';
......@@ -23,7 +22,6 @@ export const BackgroundSelector = (): JSX.Element => {
const onItemSelect = (background: MapBackground | undefined | null): void => {
if (background) {
dispatch(setMapBackground(background.id));
PluginsEventBus.dispatchEvent('onBackgroundOverlayChange', background.id);
}
};
......
......@@ -144,6 +144,10 @@ export const closeMapAndSetMainMapActiveReducer = (
};
export const setMapBackgroundReducer = (state: MapState, action: SetBackgroundAction): void => {
if (action.payload !== state.data.backgroundId) {
PluginsEventBus.dispatchEvent('onBackgroundOverlayChange', action.payload);
}
state.data.backgroundId = action.payload;
};
......
......@@ -100,7 +100,6 @@ export const getInitOverlays = createAsyncThunk<
const emptyBackgroundId = emptyBackgroundIdSelector(state);
if (emptyBackgroundId) {
dispatch(setMapBackground(emptyBackgroundId));
PluginsEventBus.dispatchEvent('onBackgroundOverlayChange', emptyBackgroundId);
}
overlaysId.forEach(id => {
......
......@@ -3,5 +3,12 @@ export const ERROR_INVALID_QUERY_TYPE = 'Invalid query type. The query should be
export const ERROR_INVALID_COORDINATES = 'Invalid coordinates type or values';
export const ERROR_INVALID_MODEL_ID_TYPE = 'Invalid model id type. The model id should be a number';
export const ERROR_PROJECT_NOT_FOUND = 'Project does not exist';
export const ERROR_PROJECT_ID_NOT_FOUND = 'Project id does not exist';
export const ERROR_OVERLAY_NAME_NOT_PROVIDED = 'Overlay name is not provided';
export const ERROR_FAILED_TO_READ_FILE = 'Failed to read file';
export const ERROR_INVALID_TYPE_FILE_CONTENT = 'Invalid type of fileContent';
export const ERROR_OVERLAY_ID_NOT_FOUND = 'Overlay with provided id does not exist';
export const ERROR_OVERLAY_ID_NOT_ACTIVE = 'Overlay with provided id is not active';
export const ERROR_OVERLAY_ID_ALREADY_ACTIVE = 'Overlay with provided id is already active';
export const ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL =
'Unable to retrieve the id of the active map: the modelId is not a number';
export const DEFAULT_TYPE = 'GENERIC';
export const DEFAULT_FILE_NAME = 'unknown.txt';
import {
createdOverlayFileFixture,
createdOverlayFixture,
uploadedOverlayFileContentFixture,
} from '@/models/fixtures/overlaysFixture';
import { projectFixture } from '@/models/fixtures/projectFixture';
import { apiPath } from '@/redux/apiPath';
import { RootState, store } from '@/redux/store';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import { HttpStatusCode } from 'axios';
import { addOverlay } from '@/redux/overlays/overlays.thunks';
import {
ERROR_OVERLAY_NAME_NOT_PROVIDED,
ERROR_PROJECT_ID_NOT_FOUND,
} from '@/services/pluginsManager/errorMessages';
import { addDataOverlay } from './addDataOverlay';
import { DEFAULT_FILE_NAME, DEFAULT_TYPE } from './addDataOverlay.constants';
jest.mock('../../../../../redux/store');
jest.mock('../../../../../redux/overlays/overlays.thunks');
const MOCK_STATE = {
project: {
data: {
...projectFixture,
},
loading: 'succeeded',
error: { message: '', name: '' },
},
};
const mockedAxiosClient = mockNetworkResponse();
describe('addDataOverlay', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const getStateSpy = jest.spyOn(store, 'getState');
const overlay = {
name: 'Mock Overlay',
description: 'Mock Description',
filename: 'mockFile.txt',
fileContent: 'Mock File Content',
type: 'mockType',
};
it('should add overlay with provided data', async () => {
mockedAxiosClient
.onPost(apiPath.createOverlayFile())
.reply(HttpStatusCode.Ok, createdOverlayFileFixture);
mockedAxiosClient
.onPost(apiPath.uploadOverlayFileContent(createdOverlayFileFixture.id))
.reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture);
mockedAxiosClient
.onPost(apiPath.createOverlay(projectFixture.projectId))
.reply(HttpStatusCode.Ok, createdOverlayFixture);
getStateSpy.mockImplementation(() => MOCK_STATE as RootState);
await addDataOverlay(overlay);
expect(addOverlay).toHaveBeenCalledWith({
content: overlay.fileContent,
description: overlay.description,
filename: overlay.filename,
name: overlay.name,
projectId: projectFixture.projectId,
type: overlay.type,
});
});
it('should throw error when project id is not found', async () => {
getStateSpy.mockImplementation(
() =>
({
project: {
...MOCK_STATE.project,
data: {
...MOCK_STATE.project.data,
projectId: '',
},
},
}) as RootState,
);
await expect(() => addDataOverlay(overlay)).rejects.toThrow(ERROR_PROJECT_ID_NOT_FOUND);
});
it('should throw error when overlay name is not provided', async () => {
getStateSpy.mockImplementation(() => MOCK_STATE as RootState);
const overlayWithoutName = {
...overlay,
name: '',
};
await expect(addDataOverlay(overlayWithoutName)).rejects.toThrow(
ERROR_OVERLAY_NAME_NOT_PROVIDED,
);
});
it('should add overlay with default values when optional parameters are not provided', async () => {
getStateSpy.mockImplementation(() => MOCK_STATE as RootState);
const overlayWithoutDefaultValues = {
name: 'Mock Overlay',
fileContent: 'Mock File Content',
};
await addDataOverlay(overlayWithoutDefaultValues);
expect(addOverlay).toHaveBeenCalledWith({
content: overlay.fileContent,
description: '',
filename: DEFAULT_FILE_NAME,
name: overlay.name,
projectId: projectFixture.projectId,
type: DEFAULT_TYPE,
});
});
});
import { addOverlay } from '@/redux/overlays/overlays.thunks';
import { projectIdSelector } from '@/redux/project/project.selectors';
import { store } from '@/redux/store';
import {
ERROR_OVERLAY_NAME_NOT_PROVIDED,
ERROR_PROJECT_ID_NOT_FOUND,
} from '@/services/pluginsManager/errorMessages';
import { DEFAULT_FILE_NAME, DEFAULT_TYPE } from './addDataOverlay.constants';
import { getOverlayContent } from './addDataOverlay.utils';
type AddDataOverlayArgs = {
name: string;
description?: string;
filename?: string;
fileContent: string;
type?: string;
};
export const addDataOverlay = async ({
name,
description,
filename,
fileContent,
type,
}: AddDataOverlayArgs): Promise<void> => {
const { dispatch, getState } = store;
const projectId = projectIdSelector(getState());
if (!projectId) throw new Error(ERROR_PROJECT_ID_NOT_FOUND);
if (!name) throw new Error(ERROR_OVERLAY_NAME_NOT_PROVIDED);
const content = await getOverlayContent(fileContent);
dispatch(
addOverlay({
content,
description: description || '',
filename: filename || DEFAULT_FILE_NAME,
name,
projectId,
type: type || DEFAULT_TYPE,
}),
);
};
/* eslint-disable no-magic-numbers */
import {
ERROR_FAILED_TO_READ_FILE,
ERROR_INVALID_TYPE_FILE_CONTENT,
} from '@/services/pluginsManager/errorMessages';
import { getOverlayContent } from './addDataOverlay.utils';
describe('getOverlayContent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return string if fileContent is a string', async () => {
const content = 'This is a string content';
const result = await getOverlayContent(content);
expect(result).toBe(content);
});
it('should return file content if fileContent is a File object', async () => {
const fileContent = 'File content';
const file = new File([fileContent], 'test.txt', { type: 'text/plain' });
const result = await getOverlayContent(file);
expect(result).toBe(fileContent);
});
it('should throw error if fileContent is neither a string nor a File object', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(getOverlayContent(123 as any)).rejects.toThrow(ERROR_INVALID_TYPE_FILE_CONTENT);
});
it('should throw error if there is an error reading the file', async () => {
const file = new File(['File content'], 'test.txt', { type: 'text/plain' });
const error = new Error('Failed to read file');
jest.spyOn(FileReader.prototype, 'readAsText').mockImplementation(() => {
throw error;
});
await expect(getOverlayContent(file)).rejects.toThrow(ERROR_FAILED_TO_READ_FILE);
});
});
import {
ERROR_FAILED_TO_READ_FILE,
ERROR_INVALID_TYPE_FILE_CONTENT,
} from '@/services/pluginsManager/errorMessages';
const getFileContentFromFile = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(file);
reader.onload = (e): void => {
if (e.target) {
resolve(e.target.result as string);
} else {
reject(new Error(ERROR_FAILED_TO_READ_FILE));
}
};
});
};
export const getOverlayContent = async (fileContent: string | File): Promise<string> => {
if (typeof fileContent === 'string') {
return fileContent;
}
if (fileContent instanceof File) {
return getFileContentFromFile(fileContent);
}
throw new Error(ERROR_INVALID_TYPE_FILE_CONTENT);
};
export { addDataOverlay } from './addDataOverlay';
import {
OVERLAYS_PUBLIC_FETCHED_STATE_MOCK,
PUBLIC_OVERLAYS_MOCK,
USER_OVERLAYS_MOCK,
} from '@/redux/overlays/overlays.mock';
import { RootState, store } from '@/redux/store';
import { getDataOverlays } from './getDataOverlays';
describe('getDataOverlays', () => {
afterEach(() => {
jest.clearAllMocks();
});
const getStateSpy = jest.spyOn(store, 'getState');
it('should return combined overlays and user overlays if exist', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_PUBLIC_FETCHED_STATE_MOCK,
userOverlays: {
data: USER_OVERLAYS_MOCK,
error: { message: '', name: '' },
loading: 'succeeded',
},
},
}) as RootState,
);
expect(getDataOverlays()).toEqual([...PUBLIC_OVERLAYS_MOCK, ...USER_OVERLAYS_MOCK]);
});
it('should return overlays if user overlays are not present', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_PUBLIC_FETCHED_STATE_MOCK,
userOverlays: {
data: [],
error: { message: '', name: '' },
loading: 'succeeded',
},
},
}) as RootState,
);
expect(getDataOverlays()).toEqual(PUBLIC_OVERLAYS_MOCK);
});
it('should return empty array if no overlays or user overlays exist', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_PUBLIC_FETCHED_STATE_MOCK,
data: [],
userOverlays: {
data: [],
error: { message: '', name: '' },
loading: 'succeeded',
},
},
}) as RootState,
);
expect(getDataOverlays()).toEqual([]);
});
});
import {
overlaysDataSelector,
userOverlaysDataSelector,
} from '@/redux/overlays/overlays.selectors';
import { store } from '@/redux/store';
import { MapOverlay } from '@/types/models';
export const getDataOverlays = (): MapOverlay[] => {
const overlays = overlaysDataSelector(store.getState());
const userOverlays = userOverlaysDataSelector(store.getState()) || [];
return [...overlays, ...userOverlays];
};
import { DEFAULT_ERROR } from '@/constants/errors';
import { overlaysFixture } from '@/models/fixtures/overlaysFixture';
import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock';
import { RootState, store } from '@/redux/store';
import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '@/redux/overlayBioEntity/overlayBioEntity.mock';
import { getVisibleDataOverlays } from './getVisibleDataOverlays';
const ACTIVE_OVERLAYS_IDS = overlaysFixture.map(overlay => overlay.idObject);
describe('getVisibleDataOverlays', () => {
afterEach(() => {
jest.clearAllMocks();
});
const getStateSpy = jest.spyOn(store, 'getState');
it('should return active overlays', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: overlaysFixture,
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: {
...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK,
overlaysId: ACTIVE_OVERLAYS_IDS,
},
}) as RootState,
);
expect(getVisibleDataOverlays()).toEqual(overlaysFixture);
});
it('should return empty array if no active overlays', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: [],
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK,
}) as RootState,
);
expect(getVisibleDataOverlays()).toEqual([]);
});
});
import { activeOverlaysSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector';
import { store } from '@/redux/store';
import { MapOverlay } from '@/types/models';
export const getVisibleDataOverlays = (): MapOverlay[] => {
const activeOverlays = activeOverlaysSelector(store.getState());
return activeOverlays;
};
/* eslint-disable no-magic-numbers */
import { RootState, store } from '@/redux/store';
import { overlaysFixture } from '@/models/fixtures/overlaysFixture';
import { DEFAULT_ERROR } from '@/constants/errors';
import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock';
import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '@/redux/overlayBioEntity/overlayBioEntity.mock';
import { hideDataOverlay } from './hideDataOverlay';
import { PluginsEventBus } from '../../pluginsEventBus';
import { ERROR_OVERLAY_ID_NOT_ACTIVE, ERROR_OVERLAY_ID_NOT_FOUND } from '../../errorMessages';
const OVERLAY_ID = overlaysFixture[0].idObject;
describe('hideDataOverlay', () => {
afterEach(() => {
jest.clearAllMocks();
});
const getStateSpy = jest.spyOn(store, 'getState');
it('should throw error if overlay is not active', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: overlaysFixture,
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [123] },
}) as RootState,
);
expect(() => hideDataOverlay(OVERLAY_ID)).toThrow(ERROR_OVERLAY_ID_NOT_ACTIVE);
});
it('should throw error if matching overlay is not found', () => {
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: overlaysFixture,
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [OVERLAY_ID] },
}) as RootState,
);
expect(() => hideDataOverlay(431)).toThrow(ERROR_OVERLAY_ID_NOT_FOUND);
});
it('should hide overlay with provided id', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: overlaysFixture,
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [OVERLAY_ID] },
}) as RootState,
);
hideDataOverlay(OVERLAY_ID);
expect(dispatchSpy).toHaveBeenCalledWith({
payload: { overlayId: OVERLAY_ID },
type: 'overlayBioEntity/removeOverlayBioEntityForGivenOverlay',
});
});
it('should dispatch plugin event with hidden overlay', () => {
const pluginDispatchEvent = jest.spyOn(PluginsEventBus, 'dispatchEvent');
getStateSpy.mockImplementation(
() =>
({
overlays: {
...OVERLAYS_INITIAL_STATE_MOCK,
userOverlays: {
data: overlaysFixture,
loading: 'idle',
error: DEFAULT_ERROR,
},
},
overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [OVERLAY_ID] },
}) as RootState,
);
hideDataOverlay(OVERLAY_ID);
expect(pluginDispatchEvent).toHaveBeenCalledWith('onHideOverlay', overlaysFixture[0]);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment