Skip to content
Snippets Groups Projects
Commit 1f02a48f authored by Piotr Gawron's avatar Piotr Gawron
Browse files

Merge branch '307-allow-plugin-to-add-entries-to-context-menu' into 'development'

Resolve "Allow plugin to add entries to context menu"

Closes #307

See merge request !293
parents ff1bb008 fb473daf
No related branches found
No related tags found
1 merge request!293Resolve "Allow plugin to add entries to context menu"
Pipeline #97471 passed
Showing with 241 additions and 31 deletions
minerva-front (19.0.0~alpha.0) stable; urgency=medium
* Feature: support for matomo (#289)
* Feature: allow plugin to not have a panel (#306)
* Feature: allow plugin to add menu option to context menu (#307)
-- Piotr Gawron <piotr.gawron@uni.lu> Fri, 18 Oct 2024 13:00:00 +0200
......
......@@ -19,7 +19,7 @@ Below is a description of the methods, as well as the types they return. A descr
- gets list of added markers
- returns array of `Marker`
- `getShownElements`
- gets list of all currently shown content/chemicals/drugs bioentities + markers
- gets list of all currently shown content/chemicals/drugs BioEntities + markers
- returns object of
```
{
......@@ -68,12 +68,12 @@ Below is a description of the methods, as well as the types they return. A descr
- **opacity** - should be a float between `0` and `1` (example: `0.54`)
- **x** - x coord on the map [surface/pin marker only]
- **y** - y coord on the map [surface/pin marker only]
- **width** - width of surface [surface marker only]
- **height** - width of height [surface marker only]
- **number** - number presented on the pin [pin marker only]
- **width** - width of surface
- **height** - height of surface
- **number** - number presented on the pin
- **modelId** - if marker should be visible only on single map, modelId should be provided
- **start** - start point of the line [line marker only]
- **end** - end point of the line [line marker only]
- **start** - start point of the line
- **end** - end point of the line
- adds one marker to markers list
- returns created `Marker`
- examples:
......
......@@ -36,7 +36,7 @@
## 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
- **Provided zoom value exceeds max zoom of ...**: This error occurs when `zoom` param of `setZoom` exceeds max zoom value of the selected map
- **Provided zoom value exceeds min zoom of ...**: This error occurs when `zoom` param of `setZoom` exceeds min zoom value of the selected map
......
......@@ -69,7 +69,7 @@ To listen for specific events, plugins can use the `addListener` method in `even
- onSearch - triggered after completing a search; the elements returned by the search are passed as arguments. Three separate events 'onSearch' are triggered, each with a different searched category type. Category types include: bioEntity, drugs, chemicals, reaction. Example argument:
```javascript
```
{
type: 'drugs',
searchValues: ['PRKN'],
......@@ -171,7 +171,7 @@ To listen for specific events, plugins can use the `addListener` method in `even
- onZoomChanged - triggered after changing the zoom level on the map; the zoom level and the map ID are passed as argument. Example argument:
```javascript
```json
{
"modelId": 52,
"zoom": 9.033753064925367
......@@ -180,7 +180,7 @@ To listen for specific events, plugins can use the `addListener` method in `even
- onCenterChanged - triggered after the coordinates of the map center change; the coordinates of the center and map ID are passed as argument. Example argument:
```javascript
```json
{
"modelId": 52,
"x": 8557,
......@@ -190,7 +190,7 @@ To listen for specific events, plugins can use the `addListener` method in `even
- onBioEntityClick - triggered when someone clicks on a pin; the element to which the pin is attached is passed as an argument. Example argument:
```javascript
```json
{
"id": 40072,
"modelId": 52,
......@@ -200,29 +200,33 @@ To listen for specific events, plugins can use the `addListener` method in `even
- onPinIconClick - triggered when someone clicks on a pin icon; the element to which the pin is attached is passed as an argument. Example argument:
```javascript
```json
{
"id": 40072,
"id": 40072
}
```
```javascript
Marker pin:
```json
{
"id": "b0a478ad-7e7a-47f5-8130-e96cbeaa0cfe", // marker pin
"id": "b0a478ad-7e7a-47f5-8130-e96cbeaa0cfe"
}
```
- onSurfaceClick - triggered when someone clicks on a overlay surface; the element to which the pin is attached is passed as an argument. Example argument:
- onSurfaceClick - triggered when someone clicks on an overlay surface; the element to which the pin is attached is passed as an argument. Example argument:
```javascript
```json
{
"id": 18,
"id": 18
}
```
```javascript
Surface marker overlay:
```json
{
"id": "a3a5305f-acfa-47ff-bf77-a26d017c6eb3", // surface marker overlay
"id": "a3a5305f-acfa-47ff-bf77-a26d017c6eb3"
}
```
......
### Map positon
### Map position
With use of the methods below plugins can access and modify user position data.
......@@ -33,7 +33,7 @@ window.minerva.map.setZoom(-14);
#### Get center
User position is defined as center coordinate. It's value is defined as x/y/z points of current viewport center translated to map position. Plugins can access center value and modify it.
User position is defined as center coordinate. Its value is defined as x/y/z points of current viewport center translated to map position. Plugins can access center value and modify it.
To get current position value, plugins can use the `window.minerva.map.getCenter()` method which returns current position value as an object containing `x`, `y` and `z` fields. All of them are non-negative numbers but `z` is an optional field and it defines current zoom value. If argument is invalid, `getCenter` method throws an error.
......
// const root = 'https://minerva-dev.lcsb.uni.lu';
// const root = 'https://scimap.lcsb.uni.lu';
const root = 'https://lux1.atcomp.pl';
window.config = {
BASE_API_URL: `${root}/minerva/api`,
......
......@@ -7,6 +7,7 @@ import {
import { act, render, screen } from '@testing-library/react';
import { CONTEXT_MENU_INITIAL_STATE } from '@/redux/contextMenu/contextMenu.constants';
import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture';
import { PluginsContextMenu } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu';
import { ContextMenu } from './ContextMenu.component';
const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
......@@ -24,6 +25,13 @@ const renderComponent = (initialStore?: InitialStoreState): { store: StoreType }
};
describe('ContextMenu - Component', () => {
beforeEach(() => {
PluginsContextMenu.menuItems = [];
});
afterEach(() => {
PluginsContextMenu.menuItems = [];
});
describe('when context menu is hidden', () => {
beforeEach(() => {
renderComponent({
......@@ -183,4 +191,23 @@ describe('ContextMenu - Component', () => {
expect(modal.modalName).toBe('mol-art');
});
});
it('should render context menu', () => {
const callback = jest.fn();
PluginsContextMenu.addMenu('1324235432', 'Click me', '', true, callback);
renderComponent({
contextMenu: {
...CONTEXT_MENU_INITIAL_STATE,
isOpen: true,
coordinates: [0, 0],
uniprot: '',
},
});
expect(screen.getByTestId('context-modal')).toBeInTheDocument();
expect(screen.getByTestId('context-modal')).not.toHaveClass('hidden');
expect(screen.getByText('Click me')).toBeInTheDocument();
});
});
......@@ -7,9 +7,18 @@ import { openAddCommentModal, openMolArtModalById } from '@/redux/modal/modal.sl
import React from 'react';
import { twMerge } from 'tailwind-merge';
import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT } from '@/constants/common';
import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT, ZERO } from '@/constants/common';
import { PluginsContextMenu } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu';
import { BioEntity, Reaction } from '@/types/models';
import { ClickCoordinates } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu.types';
import { currentModelSelector } from '@/redux/models/models.selectors';
import { mapDataLastPositionSelector } from '@/redux/map/map.selectors';
import { DEFAULT_ZOOM } from '@/constants/map';
export const ContextMenu = (): React.ReactNode => {
const pluginContextMenu = PluginsContextMenu.menuItems;
const model = useAppSelector(currentModelSelector);
const lastPosition = useAppSelector(mapDataLastPositionSelector);
const dispatch = useAppDispatch();
const { isOpen, coordinates } = useAppSelector(contextMenuSelector);
const unitProtId = useAppSelector(searchedBioEntityElementUniProtIdSelector);
......@@ -32,6 +41,25 @@ export const ContextMenu = (): React.ReactNode => {
dispatch(openAddCommentModal());
};
const modelId = model ? model.idObject : ZERO;
const handleCallback = (
callback: (coordinates: ClickCoordinates, element: BioEntity | Reaction | undefined) => void,
) => {
return () => {
dispatch(closeContextMenu());
return callback(
{
modelId,
x: coordinates[FIRST_ARRAY_ELEMENT],
y: coordinates[SECOND_ARRAY_ELEMENT],
zoom: lastPosition.z ? lastPosition.z : DEFAULT_ZOOM,
},
undefined,
);
};
};
return (
<div
className={twMerge(
......@@ -46,7 +74,7 @@ export const ContextMenu = (): React.ReactNode => {
>
<button
className={twMerge(
'cursor-pointer text-xs font-normal',
'w-full cursor-pointer text-left text-xs font-normal',
!isUnitProtIdAvailable() ? 'cursor-not-allowed text-greyscale-700' : '',
)}
onClick={handleOpenMolArtClick}
......@@ -57,13 +85,31 @@ export const ContextMenu = (): React.ReactNode => {
</button>
<hr />
<button
className={twMerge('cursor-pointer text-xs font-normal')}
className={twMerge('w-full cursor-pointer text-left text-xs font-normal')}
onClick={handleAddCommentClick}
type="button"
data-testid="add-comment"
>
Add comment
</button>
{pluginContextMenu.length && <hr />}
{pluginContextMenu.map(contextMenuEntry => (
<button
key={contextMenuEntry.id}
id={contextMenuEntry.id}
className={twMerge(
'cursor-pointer text-xs font-normal',
contextMenuEntry.style,
!contextMenuEntry.enabled ? 'cursor-not-allowed text-greyscale-700' : '',
)}
onClick={handleCallback(contextMenuEntry.callback)}
type="button"
data-testid={contextMenuEntry.id}
>
{contextMenuEntry.name}
</button>
))}
</div>
);
};
/* eslint-disable no-magic-numbers */
import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock';
import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
import { PluginsContextMenu } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu';
const plugin = PLUGINS_MOCK[FIRST_ARRAY_ELEMENT];
jest.mock('../../../utils/showToast');
describe('PluginsContextMenu', () => {
beforeEach(() => {
PluginsContextMenu.menuItems = [];
});
afterEach(() => {
PluginsContextMenu.menuItems = [];
});
describe('addMenu', () => {
it('add store context menu', () => {
const callback = jest.fn();
const id = PluginsContextMenu.addMenu(plugin.hash, 'Click me', '', true, callback);
expect(PluginsContextMenu.menuItems).toEqual([
{
hash: plugin.hash,
style: '',
name: 'Click me',
enabled: true,
id,
callback,
},
]);
});
it('update store context menu', () => {
const callback = jest.fn();
const id = PluginsContextMenu.addMenu(plugin.hash, 'Click me', '', true, callback);
PluginsContextMenu.updateMenu(plugin.hash, id, 'New name', '.stop-me', false);
expect(PluginsContextMenu.menuItems).toEqual([
{
hash: plugin.hash,
style: '.stop-me',
name: 'New name',
enabled: false,
id,
callback,
},
]);
});
it('remove from store context menu', () => {
const callback = jest.fn();
const id = PluginsContextMenu.addMenu(plugin.hash, 'Click me', '', true, callback);
PluginsContextMenu.removeMenu(plugin.hash, id);
expect(PluginsContextMenu.menuItems).toEqual([]);
});
});
});
import { PluginsContextMenuType } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu.types';
import { v4 as uuidv4 } from 'uuid';
import { ZERO } from '@/constants/common';
export const PluginsContextMenu: PluginsContextMenuType = {
menuItems: [],
addMenu: (hash, name, style, enabled, callback) => {
const uuid = uuidv4();
PluginsContextMenu.menuItems.push({
hash,
callback,
enabled,
name,
style,
id: uuid,
});
return uuid;
},
removeMenu: (hash, id) => {
PluginsContextMenu.menuItems = PluginsContextMenu.menuItems.filter(
item => item.hash !== hash || item.id !== id,
);
},
updateMenu: (hash, id, name, style, enabled) => {
const originalItems = PluginsContextMenu.menuItems.filter(
item => item.hash === hash && item.id === id,
);
if (originalItems.length > ZERO) {
originalItems[ZERO].name = name;
originalItems[ZERO].style = style;
originalItems[ZERO].enabled = enabled;
} else {
throw new Error(`Cannot find menu item with id=${id}`);
}
},
};
import { BioEntity, Reaction } from '@/types/models';
export type ClickCoordinates = {
modelId: number;
x: number;
y: number;
zoom: number;
};
export type PluginContextMenuItemType = {
id: string;
hash: string;
name: string;
style: string;
enabled: boolean;
callback: (coordinates: ClickCoordinates, element: BioEntity | Reaction | undefined) => void;
};
export type PluginsContextMenuType = {
menuItems: PluginContextMenuItemType[];
addMenu: (
hash: string,
name: string,
style: string,
enabled: boolean,
callback: (coordinates: ClickCoordinates, element: BioEntity | Reaction | undefined) => void,
) => string;
removeMenu: (hash: string, id: string) => void;
updateMenu: (hash: string, id: string, name: string, style: string, enabled: boolean) => void;
};
......@@ -8,6 +8,7 @@ import { isPluginHashWithPrefix } from '@/utils/plugins/isPluginHashWithPrefix';
import { getPluginHashWithoutPrefix } from '@/utils/plugins/getPluginHashWithoutPrefix';
import { ONE, ZERO } from '@/constants/common';
import { minervaDefine } from '@/services/pluginsManager/map/minervaDefine';
import { PluginsContextMenu } from '@/services/pluginsManager/pluginContextMenu/pluginsContextMenu';
import { bioEntitiesMethods } from './bioEntities';
import { getModels } from './map/models/getModels';
import { openMap } from './map/openMap';
......@@ -56,16 +57,16 @@ export const PluginsManager: PluginsManagerType = {
pluginsOccurrences: {},
unloadActivePlugin: hash => {
const hashWihtoutPrefix = getPluginHashWithoutPrefix(hash);
const hashWithoutPrefix = getPluginHashWithoutPrefix(hash);
PluginsManager.activePlugins[hashWihtoutPrefix] =
PluginsManager.activePlugins[hashWihtoutPrefix]?.filter(el => el !== hash) || [];
PluginsManager.activePlugins[hashWithoutPrefix] =
PluginsManager.activePlugins[hashWithoutPrefix]?.filter(el => el !== hash) || [];
if (
PluginsManager.activePlugins[hashWihtoutPrefix].length === ZERO &&
hashWihtoutPrefix in PluginsManager.pluginsOccurrences
PluginsManager.activePlugins[hashWithoutPrefix].length === ZERO &&
hashWithoutPrefix in PluginsManager.pluginsOccurrences
) {
PluginsManager.pluginsOccurrences[hashWihtoutPrefix] = ZERO;
PluginsManager.pluginsOccurrences[hashWithoutPrefix] = ZERO;
}
},
init() {
......@@ -200,6 +201,11 @@ export const PluginsManager: PluginsManagerType = {
removeListener: PluginsEventBus.removeListener.bind(this, extendedHash),
removeAllListeners: PluginsEventBus.removeAllListeners.bind(this, extendedHash),
},
contextMenu: {
addMenu: PluginsContextMenu.addMenu.bind(this, extendedHash),
updateMenu: PluginsContextMenu.updateMenu.bind(this, extendedHash),
removeMenu: PluginsContextMenu.removeMenu.bind(this, extendedHash),
},
legend: {
setLegend: setLegend.bind(this, extendedHash),
removeLegend: removeLegend.bind(this, extendedHash),
......
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