Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • minerva/frontend
1 result
Show changes
Commits on Source (29)
Showing
with 1315 additions and 12 deletions
# Plugin Integration with Minerva
To seamlessly integrate your plugin with Minerva, follow these guidelines to ensure smooth registration, HTML structure creation, and interaction with Minerva.
## Registering plugin with Minerva
Your plugin should utilize the `window.minerva.plugins.registerPlugin` method for plugin registration. When the plugin is initialized, this method should be called inside plugin initialization method. The function `window.minerva.plugins.registerPlugin` takes an object as an argument:
```ts
{
pluginName: string;
pluginVersion: string;
pluginUrl: string;
}
```
##### Usage example:
```javascript
window.minerva.plugins.registerPlugin({
pluginName: 'Your Plugin Name',
pluginVersion: '1.8.3',
pluginUrl: 'https://example.com/plugins/plugin.js',
});
```
## Creating Plugin's HTML Structure
The `window.minerva.plugins.registerPlugin` method returns object with `element` property which is a DOM element, allowing your plugin to append its HTML content to the DOM. Use this element to create and modify the HTML structure of your plugin.
```
// Plugin registration
const { element } = window.minerva.plugins.registerPlugin({
pluginName: "Your Plugin Name",
pluginVersion: "1.0.0",
pluginUrl: "your-plugin-url",
});
// Modify plugin's HTML structure
const yourContent = document.createElement('div');
yourContent.textContent = "Your Plugin Content";
element.appendChild(yourContent);
```
## Interacting with Minerva
All interactions with Minerva should happen through the `window.minerva` object. This object includes:
- configuration: includes information about available types of elements, reactions, miriam types, configuration options, map types and so on
- methods will be added in the future
## Example of plugin code before bundling:
```javascript
require('../css/styles.css');
const $ = require('jquery');
let pluginContainer;
const createStructure = () => {
$(
`<div class="flex flex-col items-center p-2.5">
<h1 class="text-lg">My plugin ${minerva.configuration.overlayTypes[0].name}</h1>
<input class="mt-2.5 p-2.5 rounded-s font-semibold outline-none border border-[#cacaca] bg-[#f7f7f8]" value="https://minerva-dev.lcsb.uni.lu/minerva">
</div>`,
).appendTo(pluginContainer);
};
function initPlugin() {
const { element } = window.minerva.plugins.registerPlugin({
pluginName: 'perfect-plugin',
pluginVersion: '9.9.9',
pluginUrl: 'https://example.com/plugins/perfect-plugin.js',
});
pluginContainer = element;
createStructure();
}
initPlugin();
```
import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager';
type Plugin = {
pluginName: string;
pluginVersion: string;
pluginUrl: string;
};
type RegisterPlugin = ({ pluginName, pluginVersion, pluginUrl }: Plugin) => {
element: HTMLDivElement;
};
type HashPlugin = {
pluginUrl: string;
pluginScript: string;
};
declare global {
interface Window {
minerva: {
configuration?: MinervaConfiguration;
plugins: {
registerPlugin: RegisterPlugin;
};
};
}
}
......@@ -18,6 +18,7 @@
"autoprefixer": "10.4.15",
"axios": "^1.5.1",
"axios-hooks": "^5.0.0",
"crypto-js": "^4.2.0",
"downshift": "^8.2.3",
"eslint-config-next": "13.4.19",
"molart": "github:davidhoksza/MolArt",
......@@ -27,6 +28,8 @@
"query-string": "7.1.3",
"react": "18.2.0",
"react-accessible-accordion": "^5.0.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "^8.1.2",
......@@ -41,6 +44,8 @@
"@commitlint/config-conventional": "^17.7.0",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.5",
"@types/react-redux": "^7.1.26",
"@types/redux-mock-store": "^1.0.6",
......@@ -1958,6 +1963,21 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
},
"node_modules/@reduxjs/toolkit": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz",
......@@ -2133,6 +2153,19 @@
"react-dom": "^18.0.0"
}
},
"node_modules/@testing-library/user-event": {
"version": "14.5.2",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
"integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
"dev": true,
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
......@@ -2213,6 +2246,12 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true
},
"node_modules/@types/downloadjs": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@types/downloadjs/-/downloadjs-1.4.6.tgz",
......@@ -4308,6 +4347,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
......@@ -4897,6 +4941,16 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
......@@ -11527,6 +11581,43 @@
"react-dom": "^16.3.3 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
......@@ -15293,6 +15384,21 @@
"integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==",
"dev": true
},
"@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
},
"@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
},
"@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
},
"@reduxjs/toolkit": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz",
......@@ -15416,6 +15522,13 @@
"@types/react-dom": "^18.0.0"
}
},
"@testing-library/user-event": {
"version": "14.5.2",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
"integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
"dev": true,
"requires": {}
},
"@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
......@@ -15493,6 +15606,12 @@
"@babel/types": "^7.20.7"
}
},
"@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true
},
"@types/downloadjs": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@types/downloadjs/-/downloadjs-1.4.6.tgz",
......@@ -17019,6 +17138,11 @@
"which": "^2.0.1"
}
},
"crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
......@@ -17479,6 +17603,16 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"requires": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
......@@ -22160,6 +22294,26 @@
"integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==",
"requires": {}
},
"react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"requires": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
}
},
"react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"requires": {
"dnd-core": "^16.0.1"
}
},
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
......
......@@ -14,9 +14,9 @@
"check-types": "tsc --pretty --noEmit",
"prepare": "husky install",
"postinstall": "husky install",
"test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'",
"test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'",
"test:ci": "jest --config ./jest.config.ts --collectCoverage --coverageDirectory=\"./coverage\" --ci --reporters=default --reporters=jest-junit --watchAll=false --passWithNoTests --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'",
"test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'",
"test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'",
"test:ci": "jest --config ./jest.config.ts --collectCoverage --coverageDirectory=\"./coverage\" --ci --reporters=default --reporters=jest-junit --watchAll=false --passWithNoTests --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'",
"test:coverage": "jest --watchAll --coverage --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'",
"test:coveragee": "jest --coverage --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'",
"coverage": "open ./coverage/lcov-report/index.html",
......@@ -32,6 +32,7 @@
"autoprefixer": "10.4.15",
"axios": "^1.5.1",
"axios-hooks": "^5.0.0",
"crypto-js": "^4.2.0",
"downshift": "^8.2.3",
"eslint-config-next": "13.4.19",
"molart": "github:davidhoksza/MolArt",
......@@ -41,6 +42,8 @@
"query-string": "7.1.3",
"react": "18.2.0",
"react-accessible-accordion": "^5.0.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "^8.1.2",
......@@ -55,6 +58,8 @@
"@commitlint/config-conventional": "^17.7.0",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.2.2",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.5",
"@types/react-redux": "^7.1.26",
"@types/redux-mock-store": "^1.0.6",
......
import { render, screen, fireEvent } from '@testing-library/react';
import { StoreType } from '@/redux/store';
import {
InitialStoreState,
getReduxWrapperWithStore,
} from '@/utils/testing/getReduxWrapperWithStore';
import { overlayFixture } from '@/models/fixtures/overlaysFixture';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import { apiPath } from '@/redux/apiPath';
import { HttpStatusCode } from 'axios';
import { DEFAULT_ERROR } from '@/constants/errors';
import { act } from 'react-dom/test-utils';
import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock';
import { Modal } from '../Modal.component';
const mockedAxiosClient = mockNetworkResponse();
const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState);
return (
render(
<Wrapper>
<Modal />
</Wrapper>,
),
{
store,
}
);
};
describe('EditOverlayModal - component', () => {
it('should render modal with correct data', () => {
renderComponent({
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
expect(screen.getByLabelText('Name')).toBeVisible();
expect(screen.getByLabelText('Description')).toBeVisible();
expect(screen.getByTestId('overlay-name')).toHaveValue(overlayFixture.name);
expect(screen.getByTestId('overlay-description')).toHaveValue(overlayFixture.description);
});
it('should handle input change correctly', () => {
renderComponent({
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const overlayNameInput: HTMLInputElement = screen.getByTestId('overlay-name');
const overlayDescriptionInput: HTMLTextAreaElement = screen.getByTestId('overlay-description');
fireEvent.change(overlayNameInput, { target: { value: 'Test name' } });
fireEvent.change(overlayDescriptionInput, { target: { value: 'Descripiton' } });
expect(overlayNameInput.value).toBe('Test name');
expect(overlayDescriptionInput.value).toBe('Descripiton');
});
it('should handle remove user overlay', async () => {
const { store } = renderComponent({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: 'test',
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
overlays: OVERLAYS_INITIAL_STATE_MOCK,
});
mockedAxiosClient
.onDelete(apiPath.removeOverlay(overlayFixture.idObject))
.reply(HttpStatusCode.Ok, {});
const removeButton = screen.getByTestId('remove-button');
expect(removeButton).toBeVisible();
await act(() => {
removeButton.click();
});
const { loading } = store.getState().overlays.removeOverlay;
expect(loading).toBe('succeeded');
expect(removeButton).not.toBeVisible();
});
it('should handle save edited user overlay', async () => {
const { store } = renderComponent({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: 'test',
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
overlays: OVERLAYS_INITIAL_STATE_MOCK,
});
mockedAxiosClient
.onPatch(apiPath.updateOverlay(overlayFixture.idObject))
.reply(HttpStatusCode.Ok, overlayFixture);
const saveButton = screen.getByTestId('save-button');
expect(saveButton).toBeVisible();
await act(() => {
saveButton.click();
});
const { loading } = store.getState().overlays.updateOverlays;
expect(loading).toBe('succeeded');
expect(saveButton).not.toBeVisible();
});
it('should handle cancel edit user overlay', async () => {
const { store } = renderComponent({
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const cancelButton = screen.getByTestId('cancel-button');
expect(cancelButton).toBeVisible();
await act(() => {
cancelButton.click();
});
const { isOpen } = store.getState().modal;
expect(isOpen).toBe(false);
expect(cancelButton).not.toBeVisible();
});
});
import { Button } from '@/shared/Button';
import { Input } from '@/shared/Input';
import { Textarea } from '@/shared/Textarea';
import React from 'react';
import { useEditOverlay } from './hooks/useEditOverlay';
export const EditOverlayModal = (): React.ReactNode => {
const {
description,
name,
handleCancelEdit,
handleDescriptionChange,
handleNameChange,
handleRemoveOverlay,
handleSaveEditedOverlay,
} = useEditOverlay();
return (
<div className="w-full border border-t-[#E1E0E6] bg-white p-[24px]">
<form>
<label className="text-sm font-semibold" htmlFor="overlayName">
Name
<Input
type="text"
value={name}
onChange={handleNameChange}
name="overlayName"
id="overlayName"
className="mt-2.5 text-sm font-medium"
data-testid="overlay-name"
/>
</label>
<label className="mt-5 block text-sm font-semibold" htmlFor="overlayDescription">
Description
<Textarea
rows={4}
value={description}
onChange={handleDescriptionChange}
name="overlayDescription"
id="overlayDescription"
className="mt-2.5 text-sm font-medium"
data-testid="overlay-description"
/>
</label>
<div className="mt-10 flex items-center justify-between gap-5 text-center">
<Button
type="button"
variantStyles="ghost"
className="flex-1 justify-center"
onClick={handleCancelEdit}
data-testid="cancel-button"
>
Cancel
</Button>
<Button
type="button"
variantStyles="ghost"
className="flex-1 justify-center"
onClick={handleRemoveOverlay}
data-testid="remove-button"
>
Remove
</Button>
<Button
type="button"
className="flex-1 justify-center"
onClick={handleSaveEditedOverlay}
data-testid="save-button"
>
Save
</Button>
</div>
</form>
</div>
);
};
/* eslint-disable no-magic-numbers */
import { DEFAULT_ERROR } from '@/constants/errors';
import { overlayFixture } from '@/models/fixtures/overlaysFixture';
import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
import { renderHook } from '@testing-library/react';
import { useEditOverlay } from './useEditOverlay';
describe('useEditOverlay', () => {
it('should handle cancel edit overlay', () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: 'test',
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const {
result: {
current: { handleCancelEdit },
},
} = renderHook(() => useEditOverlay(), {
wrapper: Wrapper,
});
handleCancelEdit();
const actions = store.getActions();
expect(actions[0].type).toBe('modal/closeModal');
});
it('should handle handleRemoveOverlay if proper data is provided', () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: 'test',
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const {
result: {
current: { handleRemoveOverlay },
},
} = renderHook(() => useEditOverlay(), {
wrapper: Wrapper,
});
handleRemoveOverlay();
const actions = store.getActions();
expect(actions[0].type).toBe('overlays/removeOverlay/pending');
const { login, overlayId } = actions[0].meta.arg;
expect(login).toBe('test');
expect(overlayId).toBe(overlayFixture.idObject);
});
it('should not handle handleRemoveOverlay if proper data is not provided', () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
user: {
authenticated: true,
loading: 'failed',
error: DEFAULT_ERROR,
login: null,
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const {
result: {
current: { handleRemoveOverlay },
},
} = renderHook(() => useEditOverlay(), {
wrapper: Wrapper,
});
handleRemoveOverlay();
const actions = store.getActions();
expect(actions.length).toBe(0);
});
it('should handle handleSaveEditedOverlay if proper data is provided', () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: 'test',
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const {
result: {
current: { handleSaveEditedOverlay },
},
} = renderHook(() => useEditOverlay(), {
wrapper: Wrapper,
});
handleSaveEditedOverlay();
const actions = store.getActions();
expect(actions[0].type).toBe('overlays/updateOverlays/pending');
expect(actions[0].meta.arg).toEqual([overlayFixture]);
});
it('should not handle handleSaveEditedOverlay if proper data is not provided', () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
user: {
authenticated: true,
loading: 'succeeded',
error: DEFAULT_ERROR,
login: null,
},
modal: {
isOpen: true,
modalTitle: overlayFixture.name,
modalName: 'edit-overlay',
editOverlayState: overlayFixture,
molArtState: {},
overviewImagesState: {},
},
});
const {
result: {
current: { handleSaveEditedOverlay },
},
} = renderHook(() => useEditOverlay(), {
wrapper: Wrapper,
});
handleSaveEditedOverlay();
const actions = store.getActions();
expect(actions.length).toBe(0);
});
});
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { currentEditedOverlaySelector } from '@/redux/modal/modal.selector';
import { closeModal } from '@/redux/modal/modal.slice';
import {
getAllUserOverlaysByCreator,
removeOverlay,
updateOverlays,
} from '@/redux/overlays/overlays.thunks';
import { loginUserSelector } from '@/redux/user/user.selectors';
import { MapOverlay } from '@/types/models';
import { useState } from 'react';
type UseEditOverlayReturn = {
name: string | undefined;
description: string | undefined;
handleCancelEdit: () => void;
handleRemoveOverlay: () => void;
handleSaveEditedOverlay: () => Promise<void>;
handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
};
type UpdatedOverlay = {
editedOverlay: MapOverlay;
overlayName: string;
overlayDescription: string;
};
export const useEditOverlay = (): UseEditOverlayReturn => {
const currentEditedOverlay = useAppSelector(currentEditedOverlaySelector);
const login = useAppSelector(loginUserSelector);
const dispatch = useAppDispatch();
const [name, setName] = useState(currentEditedOverlay?.name);
const [description, setDescription] = useState(currentEditedOverlay?.description);
const handleCancelEdit = (): void => {
dispatch(closeModal());
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setName(e.target.value);
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
setDescription(e.target.value);
};
const handleRemoveOverlay = (): void => {
if (!login || !currentEditedOverlay) return;
dispatch(removeOverlay({ overlayId: currentEditedOverlay.idObject, login }));
};
const handleUpdateOverlay = async ({
editedOverlay,
overlayDescription,
overlayName,
}: UpdatedOverlay): Promise<void> => {
await dispatch(
updateOverlays([
{
...editedOverlay,
name: overlayName,
description: overlayDescription,
},
]),
);
};
const getUserOverlaysByCreator = async (creator: string): Promise<void> => {
await dispatch(getAllUserOverlaysByCreator(creator));
};
const handleCloseModal = (): void => {
dispatch(closeModal());
};
const handleSaveEditedOverlay = async (): Promise<void> => {
if (!currentEditedOverlay || !name || !description || !login) return;
await handleUpdateOverlay({
editedOverlay: currentEditedOverlay,
overlayDescription: description,
overlayName: name,
});
await getUserOverlaysByCreator(login);
handleCloseModal();
};
return {
handleCancelEdit,
handleRemoveOverlay,
handleSaveEditedOverlay,
handleNameChange,
handleDescriptionChange,
name,
description,
};
};
export { EditOverlayModal } from './EditOverlayModal.component';
......@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
import { LoginModal } from './LoginModal';
import { MODAL_ROLE } from './Modal.constants';
import { OverviewImagesModal } from './OverviewImagesModal';
import { EditOverlayModal } from './EditOverlayModal';
const MolArtModal = dynamic(
() => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal),
......@@ -35,6 +36,7 @@ export const Modal = (): React.ReactNode => {
className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]',
modalName === 'edit-overlay' && 'h-auto w-[450px]',
)}
>
<div className="flex items-center justify-between bg-white p-[24px] text-xl">
......@@ -46,6 +48,7 @@ export const Modal = (): React.ReactNode => {
{isOpen && modalName === 'overview-images' && <OverviewImagesModal />}
{isOpen && modalName === 'mol-art' && <MolArtModal />}
{isOpen && modalName === 'login' && <LoginModal />}
{isOpen && modalName === 'edit-overlay' && <EditOverlayModal />}
</div>
</div>
</div>
......
......@@ -15,7 +15,7 @@ export const NavBar = (): JSX.Element => {
};
const openDrawerPlugins = (): void => {
dispatch(openDrawer('plugins'));
dispatch(openDrawer('available-plugins'));
};
const openDrawerExport = (): void => {
......@@ -34,14 +34,14 @@ export const NavBar = (): JSX.Element => {
<div className="flex min-h-full w-[88px] flex-col items-center justify-between bg-cultured py-8">
<div data-testid="nav-buttons">
<div className="mb-8 flex flex-col gap-[10px]">
<IconButton icon="info" onClick={openDrawerInfo} />
<IconButton icon="page" />
<IconButton icon="plugin" onClick={openDrawerPlugins} />
<IconButton icon="export" onClick={openDrawerExport} />
<IconButton icon="info" onClick={openDrawerInfo} title="Project info" />
<IconButton icon="page" title="API Doc" />
<IconButton icon="plugin" onClick={openDrawerPlugins} title="Available plugins" />
<IconButton icon="export" onClick={openDrawerExport} title="Export" />
</div>
<div className="flex flex-col gap-[10px]">
<IconButton icon="admin" onClick={openModalLogin} />
<IconButton icon="legend" onClick={openDrawerLegend} />
<IconButton icon="admin" onClick={openModalLogin} title="Login" />
<IconButton icon="legend" onClick={openDrawerLegend} title="Legend" />
</div>
</div>
......
......@@ -20,10 +20,17 @@ export const TopBar = (): JSX.Element => {
<div className="flex flex-row items-center">
<UserAvatar />
<SearchBar />
<Button icon="plus" isIcon isFrontIcon className="ml-8 mr-4" onClick={onSubmapsClick}>
<Button
icon="plus"
isIcon
isFrontIcon
className="ml-8 mr-4"
onClick={onSubmapsClick}
title="Submaps"
>
Submaps
</Button>
<Button icon="plus" isIcon isFrontIcon onClick={onOverlaysClick}>
<Button icon="plus" isIcon isFrontIcon onClick={onOverlaysClick} title="Overlays">
Overlays
</Button>
</div>
......
import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock';
import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures';
import { StoreType } from '@/redux/store';
import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { render, screen } from '@testing-library/react';
import { AvailablePluginsDrawer } from './AvailablePluginsDrawer.component';
const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
return (
render(
<Wrapper>
<AvailablePluginsDrawer />
</Wrapper>,
),
{
store,
}
);
};
describe('AvailablePluginsDrawer - component', () => {
describe('when always', () => {
it('should render drawer heading', () => {
renderComponent(INITIAL_STORE_STATE_MOCK);
const drawerTitle = screen.getByText('Available plugins');
expect(drawerTitle).toBeInTheDocument();
});
it('should render load plugin from url', () => {
renderComponent(INITIAL_STORE_STATE_MOCK);
const loadPluginFromUrlInput = screen.getByTestId('load-plugin-input-url');
expect(loadPluginFromUrlInput).toBeInTheDocument();
});
it.each(PLUGINS_MOCK)('should render render all public plugins', currentPlugin => {
renderComponent({
...INITIAL_STORE_STATE_MOCK,
plugins: {
...INITIAL_STORE_STATE_MOCK.plugins,
list: {
...INITIAL_STORE_STATE_MOCK.plugins.list,
data: PLUGINS_MOCK,
},
},
});
const pluginLabel = screen.getByText(currentPlugin.name);
expect(pluginLabel).toBeInTheDocument();
});
});
});
import { DrawerHeading } from '@/shared/DrawerHeading';
import { LoadPluginFromUrl } from './LoadPluginFromUrl';
import { PublicPlugins } from './PublicPlugins';
import { PrivateActivePlugins } from './PrivateActivePlugins';
export const AvailablePluginsDrawer = (): JSX.Element => {
return (
<div className="h-full max-h-full" data-testid="available-plugins-drawer">
<DrawerHeading title="Available plugins" />
<div className="flex flex-col gap-6 p-6">
<LoadPluginFromUrl />
<PrivateActivePlugins />
<PublicPlugins />
</div>
</div>
);
};
/* eslint-disable no-magic-numbers */
import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock';
import { render, screen } from '@testing-library/react';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener';
import { StoreType } from '@/redux/store';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import MockAdapter from 'axios-mock-adapter';
import axios, { HttpStatusCode } from 'axios';
import { apiPath } from '@/redux/apiPath';
import { act } from 'react-dom/test-utils';
import { PLUGINS_INITIAL_STATE_LIST_MOCK } from '@/redux/plugins/plugins.mock';
import { LoadPlugin, Props } from './LoadPlugin.component';
const mockedAxiosApiClient = mockNetworkResponse();
const mockedAxiosClient = new MockAdapter(axios);
const renderComponent = (
{ plugin }: Props,
initialStore?: InitialStoreState,
): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
return (
render(
<Wrapper>
<LoadPlugin plugin={plugin} />
</Wrapper>,
),
{
store,
}
);
};
describe('LoadPlugin - component', () => {
describe('when always', () => {
const plugin = PLUGINS_MOCK[FIRST_ARRAY_ELEMENT];
it('renders plugin name', () => {
renderComponent({ plugin });
const title = screen.getByText(plugin.name);
expect(title).toBeInTheDocument();
});
it('renders plugin load button', () => {
renderComponent({ plugin });
const loadButton = screen.getByText('Load');
expect(loadButton.tagName).toBe('BUTTON');
expect(loadButton).toBeInTheDocument();
});
it('should change button label to unload if plugin is active', () => {
renderComponent(
{ plugin },
{
plugins: {
activePlugins: {
data: {
[plugin.hash]: plugin,
},
pluginsId: [plugin.hash],
},
list: PLUGINS_INITIAL_STATE_LIST_MOCK,
},
},
);
expect(screen.queryByTestId('toggle-plugin')).toHaveTextContent('Unload');
});
it('should change button label to load if plugin is not active', () => {
renderComponent({ plugin });
expect(screen.queryByTestId('toggle-plugin')).toHaveTextContent('Load');
});
it('should unload plugin after click', async () => {
mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, plugin);
mockedAxiosApiClient.onGet(apiPath.getPlugin(plugin.hash)).reply(HttpStatusCode.Ok, plugin);
mockedAxiosClient.onGet(plugin.urls[0]).reply(HttpStatusCode.Ok, '');
const { store } = renderComponent(
{ plugin },
{
plugins: {
activePlugins: {
data: {
[plugin.hash]: plugin,
},
pluginsId: [plugin.hash],
},
list: PLUGINS_INITIAL_STATE_LIST_MOCK,
},
},
);
await act(() => {
screen.queryByTestId('toggle-plugin')?.click();
});
const { activePlugins } = store.getState().plugins;
expect(activePlugins.pluginsId).toEqual([]);
expect(activePlugins.data).toEqual({});
});
});
});
/* eslint-disable no-magic-numbers */
import { Button } from '@/shared/Button';
import { MinervaPlugin } from '@/types/models';
import { useLoadPlugin } from './hooks/useLoadPlugin';
export interface Props {
plugin: MinervaPlugin;
}
export const LoadPlugin = ({ plugin }: Props): JSX.Element => {
const { isPluginActive, togglePlugin, isPluginLoading } = useLoadPlugin({
hash: plugin.hash,
pluginUrl: plugin.urls[0],
});
return (
<div className="flex w-full items-center justify-between text-sm">
<span className="text-cetacean-blue">{plugin.name}</span>
<Button
variantStyles="secondary"
className="h-10 self-end rounded-e rounded-s text-xs font-medium"
onClick={togglePlugin}
data-testid="toggle-plugin"
disabled={isPluginLoading}
>
{isPluginActive ? 'Unload' : 'Load'}
</Button>
</div>
);
};
/* eslint-disable no-magic-numbers */
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import axios, { HttpStatusCode } from 'axios';
import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures';
import MockAdapter from 'axios-mock-adapter';
import { PluginsManager } from '@/services/pluginsManager';
import { pluginFixture } from '@/models/fixtures/pluginFixture';
import { useLoadPlugin } from './useLoadPlugin';
const mockedAxiosClient = new MockAdapter(axios);
jest.mock('../../../../../../services/pluginsManager/pluginsManager');
describe('useLoadPlugin', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should unload plugin successfully', async () => {
const { Wrapper, store } = getReduxStoreWithActionsListener({
...INITIAL_STORE_STATE_MOCK,
plugins: {
activePlugins: {
pluginsId: [pluginFixture.hash],
data: {
[pluginFixture.hash]: pluginFixture,
},
},
list: INITIAL_STORE_STATE_MOCK.plugins.list,
},
});
const {
result: {
current: { isPluginActive, isPluginLoading, togglePlugin },
},
} = renderHook(
() => useLoadPlugin({ hash: pluginFixture.hash, pluginUrl: pluginFixture.urls[0] }),
{
wrapper: Wrapper,
},
);
expect(isPluginActive).toBe(true);
expect(isPluginLoading).toBe(false);
act(() => {
togglePlugin();
});
const actions = store.getActions();
expect(actions[0]).toEqual({
payload: { pluginId: pluginFixture.hash },
type: 'plugins/removePlugin',
});
});
it('should load plugin successfully', async () => {
const hash = 'pluginHash';
const pluginUrl = 'http://example.com/plugin.js';
Math.max = jest.fn();
const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK);
const pluginScript = `function init() {${Math.max(1, 2)}} init()`;
mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Ok, pluginScript);
const {
result: {
current: { isPluginActive, isPluginLoading, togglePlugin },
},
} = renderHook(() => useLoadPlugin({ hash, pluginUrl }), {
wrapper: Wrapper,
});
expect(isPluginActive).toBe(false);
expect(isPluginLoading).toBe(false);
togglePlugin();
expect(Math.max).toHaveBeenCalledWith(1, 2);
await waitFor(() => {
expect(PluginsManager.setHashedPlugin).toHaveBeenCalledWith({
pluginScript,
pluginUrl,
});
});
});
});
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { isPluginActiveSelector, isPluginLoadingSelector } from '@/redux/plugins/plugins.selectors';
import { removePlugin } from '@/redux/plugins/plugins.slice';
import { PluginsManager } from '@/services/pluginsManager';
import axios from 'axios';
type UseLoadPluginReturnType = {
togglePlugin: () => void;
isPluginActive: boolean;
isPluginLoading: boolean;
};
type UseLoadPluginProps = {
hash: string;
pluginUrl: string;
};
export const useLoadPlugin = ({ hash, pluginUrl }: UseLoadPluginProps): UseLoadPluginReturnType => {
const isPluginActive = useAppSelector(state => isPluginActiveSelector(state, hash));
const isPluginLoading = useAppSelector(state => isPluginLoadingSelector(state, hash));
const dispatch = useAppDispatch();
const handleLoadPlugin = async (): Promise<void> => {
const response = await axios(pluginUrl);
const pluginScript = response.data;
/* eslint-disable no-new-func */
const loadPlugin = new Function(pluginScript);
PluginsManager.setHashedPlugin({
pluginUrl,
pluginScript,
});
loadPlugin();
};
const handleUnloadPlugin = (): void => {
dispatch(removePlugin({ pluginId: hash }));
};
const togglePlugin = (): void => {
if (isPluginActive) {
handleUnloadPlugin();
} else {
handleLoadPlugin();
}
};
return {
togglePlugin,
isPluginActive,
isPluginLoading,
};
};
export { LoadPlugin } from './LoadPlugin.component';
/* eslint-disable no-magic-numbers */
import { fireEvent, render, screen } from '@testing-library/react';
import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener';
import { StoreType } from '@/redux/store';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import MockAdapter from 'axios-mock-adapter';
import axios, { HttpStatusCode } from 'axios';
import { apiPath } from '@/redux/apiPath';
import { pluginFixture } from '@/models/fixtures/pluginFixture';
import { act } from 'react-dom/test-utils';
import { PLUGINS_INITIAL_STATE_LIST_MOCK } from '@/redux/plugins/plugins.mock';
import { LoadPluginFromUrl } from './LoadPluginFromUrl.component';
const mockedAxiosApiClient = mockNetworkResponse();
const mockedAxiosClient = new MockAdapter(axios);
const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
return (
render(
<Wrapper>
<LoadPluginFromUrl />
</Wrapper>,
),
{
store,
}
);
};
describe('LoadPluginFromUrl - component', () => {
global.URL.canParse = jest.fn();
afterEach(() => {
jest.restoreAllMocks();
});
describe('when always', () => {
it('renders plugin input label', () => {
renderComponent();
const pluginInputLabel = screen.getByLabelText('URL:');
expect(pluginInputLabel).toBeInTheDocument();
});
it('renders plugin input', () => {
renderComponent();
const pluginInput = screen.getByTestId('load-plugin-input-url');
expect(pluginInput).toBeInTheDocument();
});
it('renders plugin load button', () => {
renderComponent();
const loadButton = screen.getByText('Load');
expect(loadButton.tagName).toBe('BUTTON');
expect(loadButton).toBeInTheDocument();
});
it('should unload plugin after click', async () => {
mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture);
mockedAxiosApiClient
.onGet(apiPath.getPlugin(pluginFixture.hash))
.reply(HttpStatusCode.Ok, pluginFixture);
mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, '');
renderComponent();
const input = screen.getByTestId('load-plugin-input-url');
expect(input).toBeVisible();
act(() => {
fireEvent.change(input, { target: { value: pluginFixture.urls[0] } });
});
expect(input).toHaveValue(pluginFixture.urls[0]);
const button = screen.queryByTestId('load-plugin-button');
expect(button).toBeVisible();
act(() => {
button?.click();
});
expect(button).toBeDisabled();
});
it('should not load plugin if it`s loaded already', async () => {
global.URL.canParse = jest.fn().mockReturnValue(true);
const plugin = {
...pluginFixture,
urls: ['https://example.com/min.js'],
};
mockedAxiosClient.onGet(plugin.urls[0]).reply(HttpStatusCode.Ok, '');
const { store } = renderComponent({
plugins: {
activePlugins: {
pluginsId: [plugin.hash],
data: {
[plugin.hash]: plugin,
},
},
list: PLUGINS_INITIAL_STATE_LIST_MOCK,
},
});
const input = screen.getByTestId('load-plugin-input-url');
expect(input).toBeVisible();
act(() => {
fireEvent.change(input, { target: { value: plugin.urls[0] } });
});
expect(input).toHaveValue(plugin.urls[0]);
const button = screen.getByTestId('load-plugin-button');
expect(button).not.toBeDisabled();
await act(() => {
button.click();
});
const { activePlugins } = store.getState().plugins;
expect(activePlugins).toEqual({
pluginsId: [plugin.hash],
data: {
[plugin.hash]: plugin,
},
});
expect(input).toHaveValue('');
});
it('should disable url input if url is empty', async () => {
renderComponent();
const input = screen.getByTestId('load-plugin-input-url');
expect(input).toBeVisible();
act(() => {
fireEvent.change(input, { target: { value: '' } });
});
expect(input).toHaveValue('');
const button = screen.getByTestId('load-plugin-button');
expect(button).toBeDisabled();
});
it('should disable url input if url is not correct', async () => {
global.URL.canParse = jest.fn().mockReturnValue(false);
renderComponent();
const input = screen.getByTestId('load-plugin-input-url');
expect(input).toBeVisible();
act(() => {
fireEvent.change(input, { target: { value: 'abcd' } });
});
expect(input).toHaveValue('abcd');
const button = screen.getByTestId('load-plugin-button');
expect(button).toBeDisabled();
});
});
});