diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000000000000000000000000000000000000..58af6267784516957b43981226f5e22bbd95820e --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,82 @@ +# 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(); +``` diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..248f6f8ecb2d8aa13e21e2859fbfafacc263eba2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,27 @@ +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; + }; + }; + } +} diff --git a/package-lock.json b/package-lock.json index 8ac4a5d8aac76245502f5711c51d7396a93512f7..225d517d79a374d63e5b3f97ba7717d503763fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -44,6 +45,7 @@ "@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", @@ -2244,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", @@ -4339,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", @@ -15593,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", @@ -17119,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", diff --git a/package.json b/package.json index cc4afb15ce32397ce7e992b86c1b14c1fe6fd3d8..0f1373dd7e5774c687cb662401a31c64e6d89de1 100644 --- a/package.json +++ b/package.json @@ -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", @@ -57,6 +58,7 @@ "@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", diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx index d1343d91bbf51f36f926c2af248d596790155f04..80af258edc647d357865948e2e52a9dc333dcaba 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx @@ -1,20 +1,16 @@ -import { publicPluginsListSelector } from '@/redux/plugins/plugins.selectors'; import { DrawerHeading } from '@/shared/DrawerHeading'; -import { useSelector } from 'react-redux'; -import { LoadPlugin } from './LoadPlugin'; import { LoadPluginFromUrl } from './LoadPluginFromUrl'; +import { PublicPlugins } from './PublicPlugins'; +import { PrivateActivePlugins } from './PrivateActivePlugins'; export const AvailablePluginsDrawer = (): JSX.Element => { - const publicPlugins = useSelector(publicPluginsListSelector); - 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 /> - {publicPlugins.map(plugin => ( - <LoadPlugin key={plugin.hash} plugin={plugin} /> - ))} + <PrivateActivePlugins /> + <PublicPlugins /> </div> </div> ); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx index 92c659ac76bde94184d4324be245989f7c8f4dec..ba8ea94c6ced7739c43e902ac3ac60020959b57a 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx @@ -1,10 +1,36 @@ +/* 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 renderComponent = ({ plugin }: Props): void => { - render(<LoadPlugin plugin={plugin} />); +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', () => { @@ -25,5 +51,54 @@ describe('LoadPlugin - component', () => { 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({}); + }); }); }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx index fee403ca18cb34195aaf42a559ba618eb68f821e..f129d79d0797827601fbff4068bec1b27e5fca2e 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx @@ -1,24 +1,29 @@ +/* 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 handleLoadPlugin = (): void => { - // TODO: handleLoadPlugin - }; + const { isPluginActive, togglePlugin, isPluginLoading } = useLoadPlugin({ + hash: plugin.hash, + pluginUrl: plugin.urls[0], + }); return ( - <div className="flex w-full items-center justify-between"> + <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" - onClick={handleLoadPlugin} + className="h-10 self-end rounded-e rounded-s text-xs font-medium" + onClick={togglePlugin} + data-testid="toggle-plugin" + disabled={isPluginLoading} > - Load + {isPluginActive ? 'Unload' : 'Load'} </Button> </div> ); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..04245fbf3a2191bdfc1344bee1c12d75137339c9 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts @@ -0,0 +1,88 @@ +/* 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, + }); + }); + }); +}); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b04f6549b8eb8b790302a7ff3ee31b45c822f7 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts @@ -0,0 +1,57 @@ +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, + }; +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx index e1d83a8ba9fde4258d36ad1d5df8629ac583ce57..10d387fbba06a482aa6a79be309671efaabfd01c 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -1,11 +1,40 @@ -import { render, screen } from '@testing-library/react'; +/* 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 renderComponent = (): void => { - render(<LoadPluginFromUrl />); +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(); @@ -28,5 +57,107 @@ describe('LoadPluginFromUrl - component', () => { 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(); + }); }); }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx index de486ad2bb19462bca3674a24f9a72a126d1f75c..f57224d048c4af8fd407c8c38cff19f111e8dccf 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx @@ -1,29 +1,28 @@ import { Button } from '@/shared/Button'; -import { useState } from 'react'; +import { Input } from '@/shared/Input'; +import { useLoadPluginFromUrl } from './hooks/useLoadPluginFromUrl'; export const LoadPluginFromUrl = (): JSX.Element => { - const [url, setUrl] = useState<string>(''); - - const handleLoadPlugin = (): void => { - // TODO: handleLoadPlugin - }; + const { handleChangePluginUrl, handleLoadPlugin, isPending, pluginUrl } = useLoadPluginFromUrl(); return ( <div className="flex w-full"> <label className="flex w-full flex-col gap-2 text-sm text-cetacean-blue"> <span>URL:</span> - <input - className="h-10 w-full bg-cultured p-3" + <Input + className="h-10 w-full flex-none bg-cultured p-3" type="url" - value={url} - onChange={(e): void => setUrl(e.target.value)} + value={pluginUrl} + onChange={handleChangePluginUrl} data-testid="load-plugin-input-url" /> </label> <Button variantStyles="secondary" - className="h-10 self-end rounded-e rounded-s" + className="h-10 self-end rounded-e rounded-s text-xs font-medium" onClick={handleLoadPlugin} + disabled={isPending} + data-testid="load-plugin-button" > Load </Button> diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..123e220ccc3e05c23d54bbcf62e8d9c607acb373 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -0,0 +1,57 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { activePluginsDataSelector } from '@/redux/plugins/plugins.selectors'; +import { PluginsManager } from '@/services/pluginsManager'; +import axios from 'axios'; +import { ChangeEvent, useMemo, useState } from 'react'; + +type UseLoadPluginReturnType = { + handleChangePluginUrl: (event: ChangeEvent<HTMLInputElement>) => void; + handleLoadPlugin: () => Promise<void>; + isPending: boolean; + pluginUrl: string; +}; + +export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { + const [pluginUrl, setPluginUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const activePlugins = useAppSelector(activePluginsDataSelector); + + const isPending = useMemo( + () => !pluginUrl || isLoading || !URL.canParse(pluginUrl), + [pluginUrl, isLoading], + ); + + const handleLoadPlugin = async (): Promise<void> => { + try { + setIsLoading(true); + const response = await axios(pluginUrl); + const pluginScript = response.data; + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + + const hash = PluginsManager.setHashedPlugin({ + pluginUrl, + pluginScript, + }); + + if (!(hash in activePlugins)) { + loadPlugin(); + } + + setPluginUrl(''); + } finally { + setIsLoading(false); + } + }; + const handleChangePluginUrl = (event: ChangeEvent<HTMLInputElement>): void => { + setPluginUrl(event.target.value); + }; + + return { + handleChangePluginUrl, + handleLoadPlugin, + isPending, + pluginUrl, + }; +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..557b997413b41d2299194cfe1d08caa92907c45f --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx @@ -0,0 +1,8 @@ +import { privateActivePluginsSelector } from '@/redux/plugins/plugins.selectors'; +import { useSelector } from 'react-redux'; +import { LoadPlugin } from '../LoadPlugin'; + +export const PrivateActivePlugins = (): React.ReactNode => { + const privateActivePlugins = useSelector(privateActivePluginsSelector); + return privateActivePlugins.map(plugin => <LoadPlugin key={plugin.hash} plugin={plugin} />); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a8bc8b5fb91dbd12bfdff759888d4266870c8f5 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts @@ -0,0 +1 @@ +export { PrivateActivePlugins } from './PrivateActivePlugins.component'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..674b88756126ae4460e8b81d5dfd06a1047b86cf --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx @@ -0,0 +1,9 @@ +import { publicPluginsListSelector } from '@/redux/plugins/plugins.selectors'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { LoadPlugin } from '../LoadPlugin'; + +export const PublicPlugins = (): React.ReactNode => { + const publicPlugins = useSelector(publicPluginsListSelector); + return publicPlugins.map(plugin => <LoadPlugin key={plugin.hash} plugin={plugin} />); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c1fd8a7776423954e1da451bdf1ba757e14a1db --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts @@ -0,0 +1 @@ +export { PublicPlugins } from './PublicPlugins.component'; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 5d0d77ae259b644fe129036bd5bdb759fb3b9b52..5762a59b846afa2583c0454ef3fcef59d036fddb 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -3,6 +3,8 @@ import { Map } from '@/components/Map'; import { manrope } from '@/constants/font'; import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager'; import { twMerge } from 'tailwind-merge'; +import { useEffect } from 'react'; +import { PluginsManager } from '@/services/pluginsManager'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; import { Modal } from '../FunctionalArea/Modal'; import { ContextMenu } from '../FunctionalArea/ContextMenu'; @@ -12,6 +14,12 @@ export const MinervaSPA = (): JSX.Element => { useInitializeStore(); useReduxBusQueryManager(); + useEffect(() => { + const unsubscribe = PluginsManager.init(); + + return () => unsubscribe(); + }, []); + return ( <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> diff --git a/src/models/fixtures/pluginFixture.ts b/src/models/fixtures/pluginFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e1d83f8fe79ef99b8f6326fb1f392387752e884 --- /dev/null +++ b/src/models/fixtures/pluginFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pluginSchema } from '../pluginSchema'; + +export const pluginFixture = createFixture(pluginSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/pluginSchema.ts b/src/models/pluginSchema.ts index 0204b6f05d9e797c3cbad124eb063222bcc3e6f3..2cfb0725fc31a652b5db6d5e38996002eb262af9 100644 --- a/src/models/pluginSchema.ts +++ b/src/models/pluginSchema.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import { z } from 'zod'; export const pluginSchema = z.object({ @@ -6,5 +7,5 @@ export const pluginSchema = z.object({ version: z.string(), isPublic: z.boolean(), isDefault: z.boolean(), - urls: z.array(z.string()), + urls: z.array(z.string().min(1)), }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 0c592bc7c5b8895dde55a87d0b40051491f7c7f6..5193c5e4865e5ad7d21c71896c3e666f27392ed9 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -63,5 +63,7 @@ export const apiPath = { getSourceFile: (): string => `/projects/${PROJECT_ID}:downloadSource`, getMesh: (meshId: string): string => `mesh/${meshId}`, getTaxonomy: (taxonomyId: string): string => `taxonomy/${taxonomyId}`, + registerPluign: (): string => `plugins/`, + getPlugin: (pluginId: string): string => `plugins/${pluginId}/`, getAllPlugins: (): string => `/plugins/`, }; diff --git a/src/redux/plugins/plugins.constants.ts b/src/redux/plugins/plugins.constants.ts index 0aa77a3a906e4ecbfaf61cd980008a2a1ed7a397..c3365278b4f0cfc8eaedb8832ad14afbf2d3cad9 100644 --- a/src/redux/plugins/plugins.constants.ts +++ b/src/redux/plugins/plugins.constants.ts @@ -6,4 +6,8 @@ export const PLUGINS_INITIAL_STATE: PluginsState = { loading: 'idle', error: { name: '', message: '' }, }, + activePlugins: { + data: {}, + pluginsId: [], + }, }; diff --git a/src/redux/plugins/plugins.mock.ts b/src/redux/plugins/plugins.mock.ts index 2322bed65b3f54bb96ceb55ec122d73f93650654..9b6b9c8f12621dfa722ed20e45d6aec0c69408ba 100644 --- a/src/redux/plugins/plugins.mock.ts +++ b/src/redux/plugins/plugins.mock.ts @@ -1,12 +1,18 @@ import { DEFAULT_ERROR } from '@/constants/errors'; -import { PluginsList, PluginsState } from './plugins.types'; +import { ActivePlugins, PluginsList, PluginsState } from './plugins.types'; + +export const PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK: ActivePlugins = { + data: {}, + pluginsId: [], +}; export const PLUGINS_INITIAL_STATE_LIST_MOCK: PluginsList = { - data: undefined, + data: [], loading: 'idle', error: DEFAULT_ERROR, }; export const PLUGINS_INITIAL_STATE_MOCK: PluginsState = { list: PLUGINS_INITIAL_STATE_LIST_MOCK, + activePlugins: PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK, }; diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..edc59097d4d53fe239bace4d11948391b3857847 --- /dev/null +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer, { removePlugin } from './plugins.slice'; +import { registerPlugin } from './plugins.thunks'; +import { PLUGINS_INITIAL_STATE_MOCK } from './plugins.mock'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('plugins reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(pluginsReducer(undefined, action)).toEqual(PLUGINS_INITIAL_STATE_MOCK); + }); + it('should remove overlay from store properly', () => { + const { type, payload } = store.dispatch( + removePlugin({ + pluginId: 'hash1', + }), + ); + + expect(type).toBe('plugins/removePlugin'); + expect(payload).toEqual({ pluginId: 'hash1' }); + }); + it('should update store after successful registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + + const { type } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/fulfilled'); + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data[pluginFixture.hash]).toEqual(pluginFixture); + expect(pluginsId).toContain(pluginFixture.hash); + }); + + it('should update store after failed registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + const { type, payload } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/rejected'); + expect(payload).toEqual(undefined); + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data).toEqual({}); + + expect(pluginsId).not.toContain(pluginFixture.hash); + }); + + it('should update store on loading registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data).toEqual({}); + expect(pluginsId).toContain(pluginFixture.hash); + }); +}); diff --git a/src/redux/plugins/plugins.reducers.ts b/src/redux/plugins/plugins.reducers.ts index dd5f9fc6614702add5c78b11ccbf1811ad4bab65..f046459c4c9e88879b6d7b9ee88a755e7e29ddc1 100644 --- a/src/redux/plugins/plugins.reducers.ts +++ b/src/redux/plugins/plugins.reducers.ts @@ -1,6 +1,29 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { getAllPlugins } from './plugins.thunks'; -import { PluginsState } from './plugins.types'; +import type { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import type { PluginsState, RemovePluginAction } from './plugins.types'; +import { registerPlugin, getAllPlugins } from './plugins.thunks'; + +export const removePluginReducer = (state: PluginsState, action: RemovePluginAction): void => { + const { pluginId } = action.payload; + state.activePlugins.pluginsId = state.activePlugins.pluginsId.filter(id => id !== pluginId); + delete state.activePlugins.data[pluginId]; +}; + +export const registerPluginReducer = (builder: ActionReducerMapBuilder<PluginsState>): void => { + builder.addCase(registerPlugin.pending, (state, action) => { + const { hash } = action.meta.arg; + state.activePlugins.pluginsId.push(hash); + }); + builder.addCase(registerPlugin.fulfilled, (state, action) => { + if (action.payload) { + const { hash } = action.meta.arg; + + state.activePlugins.data[hash] = action.payload; + } + }); + builder.addCase(registerPlugin.rejected, state => { + state.activePlugins.pluginsId = []; + }); +}; export const getAllPluginsReducer = (builder: ActionReducerMapBuilder<PluginsState>): void => { builder.addCase(getAllPlugins.pending, state => { diff --git a/src/redux/plugins/plugins.selectors.ts b/src/redux/plugins/plugins.selectors.ts index 5ece3dd2e529230dd8f85c413703675ebd7de90d..1bf37c638ee768446fe26c5229f4e4a51e134f14 100644 --- a/src/redux/plugins/plugins.selectors.ts +++ b/src/redux/plugins/plugins.selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; +import { MinervaPlugin } from '@/types/models'; import { rootSelector } from '../root/root.selectors'; export const pluginsSelector = createSelector(rootSelector, state => state.plugins); @@ -17,3 +18,50 @@ export const publicPluginsListSelector = createSelector( return (pluginsListData || []).filter(plugin => plugin.isPublic); }, ); + +export const activePluginsSelector = createSelector(pluginsSelector, state => state.activePlugins); + +export const activePluginsIdSelector = createSelector( + activePluginsSelector, + state => state.pluginsId, +); + +export const activePluginsDataSelector = createSelector( + activePluginsSelector, + plugins => plugins.data, +); + +export const allActivePluginsSelector = createSelector( + activePluginsDataSelector, + activePluginsIdSelector, + (data, pluginsId) => { + const result: MinervaPlugin[] = []; + + pluginsId.forEach(pluginId => { + const element = data[pluginId]; + if (element) { + result.push(element); + } + }); + + return result; + }, +); + +export const privateActivePluginsSelector = createSelector( + allActivePluginsSelector, + activePlugins => { + return (activePlugins || []).filter(plugin => !plugin.isPublic); + }, +); + +export const isPluginActiveSelector = createSelector( + [activePluginsIdSelector, (_, activePlugin: string): string => activePlugin], + (activePlugins, activePlugin) => activePlugins.includes(activePlugin), +); + +export const isPluginLoadingSelector = createSelector( + [activePluginsSelector, (_, activePlugins: string): string => activePlugins], + ({ data, pluginsId }, pluginId) => + pluginsId.includes(pluginId) && data[pluginId] && !Object.keys(data[pluginId]).length, +); diff --git a/src/redux/plugins/plugins.slice.ts b/src/redux/plugins/plugins.slice.ts index 8ac4e01130e47b994104373c7cfa0c41ad20b8ac..aeb408420001e991c41e1a060c280880450af4af 100644 --- a/src/redux/plugins/plugins.slice.ts +++ b/src/redux/plugins/plugins.slice.ts @@ -1,14 +1,23 @@ import { createSlice } from '@reduxjs/toolkit'; +import { + registerPluginReducer, + removePluginReducer, + getAllPluginsReducer, +} from './plugins.reducers'; + import { PLUGINS_INITIAL_STATE } from './plugins.constants'; -import { getAllPluginsReducer } from './plugins.reducers'; -const pluginsState = createSlice({ +const pluginsSlice = createSlice({ name: 'plugins', initialState: PLUGINS_INITIAL_STATE, - reducers: {}, + reducers: { + removePlugin: removePluginReducer, + }, extraReducers: builder => { + registerPluginReducer(builder); getAllPluginsReducer(builder); }, }); -export default pluginsState.reducer; +export const { removePlugin } = pluginsSlice.actions; +export default pluginsSlice.reducer; diff --git a/src/redux/plugins/plugins.thunks.test.ts b/src/redux/plugins/plugins.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7972222c206923167271607d9c4e844b56d55556 --- /dev/null +++ b/src/redux/plugins/plugins.thunks.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-magic-numbers */ +import axios, { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import MockAdapter from 'axios-mock-adapter'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer from './plugins.slice'; +import { getInitPlugins } from './plugins.thunks'; + +const mockedAxiosApiClient = mockNetworkResponse(); +const mockedAxiosClient = new MockAdapter(axios); + +describe('plugins - thunks', () => { + describe('getInitPlugins', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + const setHashedPluginMock = jest.fn(); + + beforeEach(() => { + setHashedPluginMock.mockClear(); + }); + + it('should fetch and load initial plugins', 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, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).toHaveBeenCalledTimes(1); + }); + it('should not load plugin if fetched plugin is not valid', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, {}); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.NotFound, {}); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.NotFound, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts index 732cfdcb4fcadb1b83a804bb51c8f581d2269f50..73c7341e07cfc2f9b93515487084fe26fade667d 100644 --- a/src/redux/plugins/plugins.thunks.ts +++ b/src/redux/plugins/plugins.thunks.ts @@ -1,11 +1,90 @@ +/* eslint-disable no-magic-numbers */ +import axios from 'axios'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { pluginSchema } from '@/models/pluginSchema'; +import type { MinervaPlugin } from '@/types/models'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; -import { MinervaPlugin } from '@/types/models'; -import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; -import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { apiPath } from '../apiPath'; +type RegisterPlugin = { + hash: string; + pluginUrl: string; + pluginName: string; + pluginVersion: string; + isPublic: boolean; +}; + +export const registerPlugin = createAsyncThunk( + 'plugins/registerPlugin', + async ({ + hash, + isPublic, + pluginName, + pluginUrl, + pluginVersion, + }: RegisterPlugin): Promise<MinervaPlugin | undefined> => { + const payload = { + hash, + url: pluginUrl, + name: pluginName, + version: pluginVersion, + isPublic: isPublic.toString(), + } as const; + + const response = await axiosInstance.post<MinervaPlugin>( + apiPath.registerPluign(), + new URLSearchParams(payload), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); + + if (isDataValid) { + return response.data; + } + + return undefined; + }, +); + +type GetInitPluginsProps = { + pluginsId: string[]; + setHashedPlugin: ({ + pluginUrl, + pluginScript, + }: { + pluginUrl: string; + pluginScript: string; + }) => void; +}; + +export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps>( + 'plugins/getInitPlugins', + async ({ pluginsId, setHashedPlugin }): Promise<void> => { + /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const pluginId of pluginsId) { + const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(pluginId)); + + const isDataValid = validateDataUsingZodSchema(res.data, pluginSchema); + + if (isDataValid) { + const { urls } = res.data; + const scriptRes = await axios(urls[0]); + const pluginScript = scriptRes.data; + setHashedPlugin({ pluginUrl: urls[0], pluginScript }); + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + loadPlugin(); + } + } + }, +); + export const getAllPlugins = createAsyncThunk( 'plugins/getAllPlugins', async (): Promise<MinervaPlugin[]> => { diff --git a/src/redux/plugins/plugins.types.ts b/src/redux/plugins/plugins.types.ts index c7d7ae4c3ffdcf7c5477ddcb3bfa0408e7c2be77..2569f12845cc3394a7b8897e9133745a52cb9fa6 100644 --- a/src/redux/plugins/plugins.types.ts +++ b/src/redux/plugins/plugins.types.ts @@ -1,8 +1,20 @@ +import { PayloadAction } from '@reduxjs/toolkit'; + import { FetchDataState } from '@/types/fetchDataState'; import { MinervaPlugin } from '@/types/models'; +export type RemovePluginPayload = { pluginId: string }; +export type RemovePluginAction = PayloadAction<RemovePluginPayload>; + export type PluginsList = FetchDataState<MinervaPlugin[]>; +export type ActivePlugins = { + pluginsId: string[]; + data: { + [pluginId: string]: MinervaPlugin; + }; +}; export type PluginsState = { list: PluginsList; + activePlugins: ActivePlugins; }; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index f6e14e6a928ae7e738fc3a267c10cd7592813379..557e87d96f6804aed9e108a81d9530a2cd93ccf2 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -1,8 +1,9 @@ -import { getDefaultSearchTab } from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; import { PROJECT_ID } from '@/constants'; import { openSearchDrawerWithSelectedTab } from '@/redux/drawer/drawer.slice'; import { AppDispatch } from '@/redux/store'; import { QueryData } from '@/types/query'; +import { getDefaultSearchTab } from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; +import { PluginsManager } from '@/services/pluginsManager'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; @@ -15,7 +16,7 @@ import { import { getModels } from '../models/models.thunks'; import { getInitOverlays } from '../overlayBioEntity/overlayBioEntity.thunk'; import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; -import { getAllPlugins } from '../plugins/plugins.thunks'; +import { getAllPlugins, getInitPlugins } from '../plugins/plugins.thunks'; import { getProjectById } from '../project/project.thunks'; import { setPerfectMatch } from '../search/search.slice'; import { getSearchData } from '../search/search.thunks'; @@ -32,6 +33,7 @@ export const fetchInitialAppData = createAsyncThunk< { dispatch: AppDispatch } >('appInit/fetchInitialAppData', async ({ queryData }, { dispatch }): Promise<void> => { /** Fetch all data required for rendering map */ + await Promise.all([ dispatch(getConfigurationOptions()), dispatch(getProjectById(PROJECT_ID)), @@ -74,4 +76,13 @@ export const fetchInitialAppData = createAsyncThunk< if (queryData.overlaysId) { dispatch(getInitOverlays({ overlaysId: queryData.overlaysId })); } + + if (queryData.pluginsId) { + dispatch( + getInitPlugins({ + pluginsId: queryData.pluginsId, + setHashedPlugin: PluginsManager.setHashedPlugin, + }), + ); + } }); diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts index 3088b0dacb0ae0051fb11a60a658af927aff4667..b862ed429fdb885367f3f87755c47204903b08b7 100644 --- a/src/redux/root/query.selectors.ts +++ b/src/redux/root/query.selectors.ts @@ -4,22 +4,26 @@ import { ZERO } from '@/constants/common'; import { mapDataSelector } from '../map/map.selectors'; import { perfectMatchSelector, searchValueSelector } from '../search/search.selectors'; import { activeOverlaysIdSelector } from '../overlayBioEntity/overlayBioEntity.selector'; +import { activePluginsIdSelector } from '../plugins/plugins.selectors'; export const queryDataParamsSelector = createSelector( searchValueSelector, perfectMatchSelector, mapDataSelector, activeOverlaysIdSelector, + activePluginsIdSelector, ( searchValue, perfectMatch, { modelId, backgroundId, position }, activeOverlaysId, + activePluginsId, ): QueryDataParams => { const joinedSearchValue = searchValue.join(';'); const shouldIncludeSearchValue = searchValue.length > ZERO && joinedSearchValue; const shouldIncludeOverlaysId = activeOverlaysId.length > ZERO; + const shouldIncludePluginsId = activePluginsId.length > ZERO; const queryDataParams: QueryDataParams = { perfectMatch, @@ -28,6 +32,7 @@ export const queryDataParamsSelector = createSelector( ...position.last, ...(shouldIncludeSearchValue ? { searchValue: joinedSearchValue } : {}), ...(shouldIncludeOverlaysId ? { overlaysId: activeOverlaysId.join(',') } : {}), + ...(shouldIncludePluginsId ? { pluginsId: activePluginsId.join(',') } : {}), }; return queryDataParams; diff --git a/src/services/pluginsManager/index.ts b/src/services/pluginsManager/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c77cb1f59759be88679e29c12d76613f1f61be64 --- /dev/null +++ b/src/services/pluginsManager/index.ts @@ -0,0 +1 @@ +export { PluginsManager } from './pluginsManager'; diff --git a/src/services/pluginsManager/pluginsManager.test.ts b/src/services/pluginsManager/pluginsManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9b55d2712e3cb23ec806e3440d373fd06bcd3d3 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-magic-numbers */ +import { store } from '@/redux/store'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { configurationMapper } from './pluginsManager.utils'; +import { PluginsManager } from './pluginsManager'; + +jest.mock('../../redux/store'); + +describe('PluginsManager', () => { + const originalWindow = { ...global.window }; + + beforeEach(() => { + global.window = { ...originalWindow }; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('setHashedPlugin correctly computes hash and updates hashedPlugins', () => { + const pluginUrl = 'https://example.com/plugin.js'; + const pluginScript = 'console.log("Hello, Plugin!");'; + + PluginsManager.setHashedPlugin({ pluginUrl, pluginScript }); + + expect(PluginsManager.hashedPlugins[pluginUrl]).toBe('edc7eeafccc9e1ab66f713298425947b'); + }); + + it('init subscribes to store changes and updates minerva configuration', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: configurationFixture } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toEqual(configurationMapper(configurationFixture)); + }); + it('init does not update minerva configuration when configuration is undefined', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: undefined } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toBeUndefined(); + }); + + it('registerPlugin dispatches action and returns element', () => { + const pluginName = 'TestPlugin'; + const pluginVersion = '1.0.0'; + const pluginUrl = 'https://example.com/test-plugin.js'; + + const result = PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + + expect(store.dispatch).toHaveBeenCalled(); + + expect(result.element).toBeDefined(); + }); +}); diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..a41a8ed5cad9d405bb07069425bf28f79b3112e5 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.ts @@ -0,0 +1,61 @@ +import md5 from 'crypto-js/md5'; +import { store } from '@/redux/store'; +import { registerPlugin } from '@/redux/plugins/plugins.thunks'; +import { configurationMapper } from './pluginsManager.utils'; +import type { PluginsManagerType } from './pluginsManager.types'; + +export const PluginsManager: PluginsManagerType = { + hashedPlugins: {}, + setHashedPlugin({ pluginUrl, pluginScript }) { + const hash = md5(pluginScript).toString(); + + PluginsManager.hashedPlugins[pluginUrl] = hash; + + return hash; + }, + init() { + window.minerva = { + plugins: { + registerPlugin: PluginsManager.registerPlugin, + }, + }; + + const unsubscribe = store.subscribe(() => { + const configurationStore = store.getState().configuration.main.data; + + if (configurationStore) { + const configuration = configurationMapper(configurationStore); + + window.minerva = { + ...window.minerva, + configuration, + }; + } + }); + + return unsubscribe; + }, + + registerPlugin({ pluginName, pluginVersion, pluginUrl }) { + const hash = PluginsManager.hashedPlugins[pluginUrl]; + + store.dispatch( + registerPlugin({ + hash, + isPublic: false, + pluginName, + pluginUrl, + pluginVersion, + }), + ); + + // TODO: replace when plugins drawer is implemented + const element = document.createElement('div'); + const wrapper = document.querySelector('#plugins'); + wrapper?.append(element); + + return { + element, + }; + }, +}; diff --git a/src/services/pluginsManager/pluginsManager.types.ts b/src/services/pluginsManager/pluginsManager.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..cedb9034b59df6bb3726b1f57e1d3109814be189 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.types.ts @@ -0,0 +1,21 @@ +import { Unsubscribe } from '@reduxjs/toolkit'; +import { configurationMapper } from './pluginsManager.utils'; + +export type RegisterPlugin = { + pluginName: string; + pluginVersion: string; + pluginUrl: string; +}; + +export type MinervaConfiguration = ReturnType<typeof configurationMapper>; + +export type PluginsManagerType = { + hashedPlugins: { + [url: string]: string; + }; + setHashedPlugin({ pluginUrl, pluginScript }: { pluginUrl: string; pluginScript: string }): string; + init(): Unsubscribe; + registerPlugin({ pluginName, pluginVersion, pluginUrl }: RegisterPlugin): { + element: HTMLDivElement; + }; +}; diff --git a/src/services/pluginsManager/pluginsManager.utils.ts b/src/services/pluginsManager/pluginsManager.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd7158a5d30ae742bbde630778dbcbb66e94a072 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.utils.ts @@ -0,0 +1,14 @@ +import { Configuration } from '@/types/models'; + +export const configurationMapper = (data: Configuration): unknown => ({ + annotators: data.annotators, + elementTypes: data.elementTypes, + miramiTypes: data.miriamTypes, + mapTypes: data.mapTypes, + modelConverters: data.modelFormats, + modificationStateTypes: data.modificationStateTypes, + options: data.options, + overlayTypes: data.overlayTypes, + privilegeTypes: data.privilegeTypes, + reactionTypes: data.reactionTypes, +}); diff --git a/src/types/query.ts b/src/types/query.ts index 98309123aeea5a80626fca86870beb56c6561ec3..be3453f011b515a134cf5aff62e1549ae31553c1 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -7,6 +7,7 @@ export interface QueryData { backgroundId?: number; initialPosition?: Partial<Point>; overlaysId?: number[]; + pluginsId?: string[]; } export interface QueryDataParams { @@ -18,6 +19,7 @@ export interface QueryDataParams { y?: number; z?: number; overlaysId?: string; + pluginsId?: string; } export interface QueryDataRouterParams { @@ -29,4 +31,5 @@ export interface QueryDataRouterParams { y?: string; z?: string; overlaysId?: string; + pluginsId?: string; } diff --git a/src/utils/initialize/useInitializeStore.ts b/src/utils/initialize/useInitializeStore.ts index 9722dd6173ea1fe82164f422f0b7d6dc106a22de..c648112775ac249fb74cc4ab3b439f803430297f 100644 --- a/src/utils/initialize/useInitializeStore.ts +++ b/src/utils/initialize/useInitializeStore.ts @@ -25,6 +25,7 @@ export const useInitializeStore = (): void => { if (isInitialized || !isQueryReady) { return; } + dispatch(fetchInitialAppData({ queryData: parseQueryToTypes(query) })); }, [dispatch, isInitialized, query, isQueryReady, isInitDataLoadingFinished]); }; diff --git a/src/utils/parseQueryToTypes.ts b/src/utils/parseQueryToTypes.ts index ee7440375a4834e13cf217b9af5fd714889ec56b..f04abadfedebada9e8058ffa3d4eae08b9ffc731 100644 --- a/src/utils/parseQueryToTypes.ts +++ b/src/utils/parseQueryToTypes.ts @@ -11,4 +11,5 @@ export const parseQueryToTypes = (query: QueryDataRouterParams): QueryData => ({ z: Number(query.z) || undefined, }, overlaysId: query.overlaysId?.split(',').map(Number), + pluginsId: query.pluginsId?.split(',').map(String), }); diff --git a/src/utils/query-manager/useReduxBusQueryManager.ts b/src/utils/query-manager/useReduxBusQueryManager.ts index 80d277dd03a6954af2085dbc98fe7c75cf169663..5a2a58772b7543ac5d79ced941ed15fb5b9e6834 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.ts @@ -11,10 +11,10 @@ export const useReduxBusQueryManager = (): void => { const handleChangeQuery = useCallback( () => + // eslint-disable-next-line react-hooks/exhaustive-deps router.replace( { query: { - ...router.query, ...queryData, }, }, @@ -23,7 +23,6 @@ export const useReduxBusQueryManager = (): void => { shallow: true, }, ), - // router is not an stable reference // eslint-disable-next-line react-hooks/exhaustive-deps [queryData], );