diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx index d092275920b566314aad7c8229e3f4b0383ed7f9..c786e777cfd43631f718e1925f7610703b03d100 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx @@ -2,6 +2,7 @@ import { DrawerHeading } from '@/shared/DrawerHeading'; import { LoadPluginFromUrl } from './LoadPluginFromUrl'; import { PublicPlugins } from './PublicPlugins'; import { PrivateActivePlugins } from './PrivateActivePlugins'; +import { PublicActivePlugins } from './PublicActivePlugins'; export const AvailablePluginsDrawer = (): JSX.Element => { return ( @@ -10,6 +11,7 @@ export const AvailablePluginsDrawer = (): JSX.Element => { <div className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col gap-6 overflow-y-auto p-6"> <LoadPluginFromUrl /> <PrivateActivePlugins /> + <PublicActivePlugins /> <PublicPlugins /> </div> </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 index 3b826b1aba1924eb0bdabfc5807dd9e2153fe956..9e98d70215a1c2df4e56cb9488f2fbae43498ace 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts @@ -199,5 +199,82 @@ describe('useLoadPlugin', () => { }, ]); }); + it('should unload plugin from plugins manager', () => { + const unloadActivePluginSpy = jest.spyOn(PluginsManager, 'unloadActivePlugin'); + const { Wrapper } = getReduxStoreWithActionsListener(STATE_MOCK); + PluginsManager.activePlugins = { + [pluginFixture.hash]: [pluginFixture.hash], + }; + + const { + result: { + current: { togglePlugin }, + }, + } = renderHook( + () => useLoadPlugin({ hash: pluginFixture.hash, pluginUrl: pluginFixture.urls[0] }), + { + wrapper: Wrapper, + }, + ); + + act(() => { + togglePlugin(); + }); + + expect(unloadActivePluginSpy).toHaveBeenCalledWith(pluginFixture.hash); + }); + it('should set last active plugin as current plugin tab in drawer', () => { + const firstHash = 'hash1'; + const secondHash = 'hash2'; + + const { Wrapper, store } = getReduxStoreWithActionsListener({ + ...STATE_MOCK, + plugins: { + ...STATE_MOCK.plugins, + activePlugins: { + data: { + [firstHash]: { + ...pluginFixture, + hash: firstHash, + }, + [secondHash]: { + ...pluginFixture, + hash: secondHash, + }, + }, + pluginsId: [firstHash, secondHash], + }, + }, + }); + + const { + result: { + current: { togglePlugin }, + }, + } = renderHook(() => useLoadPlugin({ hash: secondHash, pluginUrl: pluginFixture.urls[0] }), { + wrapper: Wrapper, + }); + + act(() => { + togglePlugin(); + }); + + const actions = store.getActions(); + + expect(actions).toEqual([ + { + payload: { pluginId: secondHash }, + type: 'plugins/removePlugin', + }, + { + payload: firstHash, + type: 'plugins/setCurrentDrawerPluginHash', + }, + { + payload: secondHash, + type: 'legend/removePluginLegend', + }, + ]); + }); }); }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts index 6ebfcea929a1c0472f8cf3493ab39056b475b823..2ec650471f0b374e8ce7c1f40edda783cf0d1000 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts @@ -3,11 +3,12 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { isActiveLegendSelector } from '@/redux/legend/legend.selectors'; import { removePluginLegend, setDefaultLegendId } from '@/redux/legend/legend.slice'; import { + allActivePluginsSelector, isPluginActiveSelector, isPluginLoadingSelector, isPluginSelectedSelector, } from '@/redux/plugins/plugins.selectors'; -import { removePlugin } from '@/redux/plugins/plugins.slice'; +import { removePlugin, setCurrentDrawerPluginHash } from '@/redux/plugins/plugins.slice'; import { PluginsManager } from '@/services/pluginsManager'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; import { getErrorMessage } from '@/utils/getErrorMessage'; @@ -40,9 +41,18 @@ export const useLoadPlugin = ({ const isPluginLoading = useAppSelector(state => isPluginLoadingSelector(state, hash)); const isPluginSelected = useAppSelector(state => isPluginSelectedSelector(state, hash)); const isActivePluginLegend = useAppSelector(state => isActiveLegendSelector(state, hash)); + const allActivePlugins = useAppSelector(allActivePluginsSelector); const dispatch = useAppDispatch(); + const setLastPluginAsCurrentActivePlugin = (): void => { + const newAllActivePlugins = allActivePlugins.filter(p => p.hash !== hash); + const lastActivePlugin = newAllActivePlugins.pop(); + if (lastActivePlugin) { + dispatch(setCurrentDrawerPluginHash(lastActivePlugin.hash)); + } + }; + const handleLoadPlugin = async (): Promise<void> => { try { const response = await axios(pluginUrl); @@ -83,9 +93,14 @@ export const useLoadPlugin = ({ const handleUnloadPlugin = (): void => { dispatch(removePlugin({ pluginId: hash })); + setLastPluginAsCurrentActivePlugin(); + handleRemoveLegend(); PluginsManager.removePluginContent({ hash }); + + PluginsManager.unloadActivePlugin(hash); + PluginsEventBus.dispatchEvent('onPluginUnload', { hash }); }; 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 1aec9dda6b880da0174461f9c0225c70a693f1e5..208136f0a71e569aad40d317399ece1a53c2b4f8 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -1,10 +1,6 @@ /* eslint-disable no-magic-numbers */ import { pluginFixture } from '@/models/fixtures/pluginFixture'; import { apiPath } from '@/redux/apiPath'; -import { - PLUGINS_INITIAL_STATE_LIST_MOCK, - PLUGINS_INITIAL_STATE_MOCK, -} from '@/redux/plugins/plugins.mock'; import { StoreType } from '@/redux/store'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; @@ -89,53 +85,7 @@ describe('LoadPluginFromUrl - component', () => { 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: { - ...PLUGINS_INITIAL_STATE_MOCK, - 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'); @@ -195,62 +145,7 @@ describe('LoadPluginFromUrl - component', () => { }); }); }); - it('should set plugin active tab in drawer as loaded plugin', async () => { - const pluginUrl = 'http://example.com/plugin.js'; - const pluginScript = `function init() {} init()`; - mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Ok, pluginScript); - - global.URL.canParse = jest.fn().mockReturnValue(true); - - const { store } = renderComponent(); - - const dispatchSpy = jest.spyOn(store, 'dispatch'); - - const input = screen.getByTestId('load-plugin-input-url'); - expect(input).toBeVisible(); - - act(() => { - fireEvent.change(input, { target: { value: pluginUrl } }); - }); - - const button = screen.getByTestId('load-plugin-button'); - - act(() => { - button.click(); - }); - - await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledWith({ - payload: 'e008fb2ceb97e3d6139ffe38a1b39d5d', - type: 'plugins/setCurrentDrawerPluginHash', - }); - }); - }); - it('should load plugin from url after pressing enter key', async () => { - const pluginUrl = 'http://example.com/plugin.js'; - const pluginScript = `function init() {} init()`; - mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Ok, pluginScript); - - global.URL.canParse = jest.fn().mockReturnValue(true); - const { store } = renderComponent(); - - const dispatchSpy = jest.spyOn(store, 'dispatch'); - - const input = screen.getByTestId('load-plugin-input-url'); - - act(() => { - fireEvent.change(input, { target: { value: pluginUrl } }); - fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); - }); - - await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledWith({ - payload: 'e008fb2ceb97e3d6139ffe38a1b39d5d', - type: 'plugins/setCurrentDrawerPluginHash', - }); - }); - }); it('should not load plugin from url after pressing enter key if url is not correct', async () => { global.URL.canParse = jest.fn().mockReturnValue(false); const { store } = renderComponent(); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index bc1cf35d906640f7d5e6d2a57b9a597e2798e7cc..54276c16149fd733bf0dddae65a8db45f21624a6 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -1,7 +1,3 @@ -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { activePluginsDataSelector } from '@/redux/plugins/plugins.selectors'; -import { setCurrentDrawerPluginHash } from '@/redux/plugins/plugins.slice'; import { PluginsManager } from '@/services/pluginsManager'; import { showToast } from '@/utils/showToast'; import axios from 'axios'; @@ -21,25 +17,19 @@ type UseLoadPluginReturnType = { export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { const [pluginUrl, setPluginUrl] = useState(''); const [isLoading, setIsLoading] = useState(false); - const activePlugins = useAppSelector(activePluginsDataSelector); - const dispatch = useAppDispatch(); const isPending = useMemo( () => !pluginUrl || isLoading || !URL.canParse(pluginUrl), [pluginUrl, isLoading], ); - const handleSetCurrentDrawerPluginHash = (hash: string): void => { - dispatch(setCurrentDrawerPluginHash(hash)); - }; - const handleLoadPlugin = async (): Promise<void> => { try { setIsLoading(true); const response = await axios(pluginUrl); let pluginScript = response.data; - const hash = PluginsManager.setHashedPlugin({ + PluginsManager.setHashedPlugin({ pluginUrl, pluginScript, }); @@ -49,13 +39,9 @@ export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { /* eslint-disable no-new-func */ const loadPlugin = new Function(pluginScript); - if (!(hash in activePlugins)) { - loadPlugin(); - } + loadPlugin(); setPluginUrl(''); - - handleSetCurrentDrawerPluginHash(hash); } catch (error) { const errorMessage = getErrorMessage({ error, prefix: PLUGIN_LOADING_ERROR_PREFIX }); showToast({ diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/PublicActivePlugins.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/PublicActivePlugins.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0bcb2c22d48c75ddb5ddc25064e599ea35b78c8 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/PublicActivePlugins.component.tsx @@ -0,0 +1,8 @@ +import { publicActivePluginsSelector } from '@/redux/plugins/plugins.selectors'; +import { useSelector } from 'react-redux'; +import { LoadPlugin } from '../LoadPlugin'; + +export const PublicActivePlugins = (): React.ReactNode => { + const publicActivePlugins = useSelector(publicActivePluginsSelector); + return publicActivePlugins.map(plugin => <LoadPlugin key={plugin.hash} plugin={plugin} />); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..92510375bf5d5ac631e644d7a145b56e7b66334b --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicActivePlugins/index.ts @@ -0,0 +1 @@ +export { PublicActivePlugins } from './PublicActivePlugins.component'; diff --git a/src/components/Map/PluginsDrawer/PluginsHeader/PluginOpenButton/PluginOpenButton.component.tsx b/src/components/Map/PluginsDrawer/PluginsHeader/PluginOpenButton/PluginOpenButton.component.tsx index c61328b48f69b4cda7c5dc9e71e5f6ff912de6ee..7483bd2f9c5c3c8bc2457bd7c33266f428960cc6 100644 --- a/src/components/Map/PluginsDrawer/PluginsHeader/PluginOpenButton/PluginOpenButton.component.tsx +++ b/src/components/Map/PluginsDrawer/PluginsHeader/PluginOpenButton/PluginOpenButton.component.tsx @@ -1,5 +1,5 @@ import { ZERO } from '@/constants/common'; -import { publicPluginsListWithoutActiveSelector } from '@/redux/plugins/plugins.selectors'; +import { publicPluginsListSelector } from '@/redux/plugins/plugins.selectors'; import { Button } from '@/shared/Button'; import { MinervaPlugin } from '@/types/models'; import { useSelect } from 'downshift'; @@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'; import { LoadPluginElement } from './LoadPluginElement'; export const PluginOpenButton = (): JSX.Element | null => { - const publicPlugins = useSelector(publicPluginsListWithoutActiveSelector); + const publicPlugins = useSelector(publicPluginsListSelector); const { isOpen, getToggleButtonProps, getMenuProps } = useSelect({ items: publicPlugins, diff --git a/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.test.tsx b/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.test.tsx index f9ff782a84cd638f1798d510b448fed1af6fdc85..e78c0dbac97bfee3f638663328f97bc670bb3a19 100644 --- a/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.test.tsx +++ b/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.test.tsx @@ -124,11 +124,11 @@ describe('PluginSingleTab - component', () => { expect(actions).toEqual([ { payload: { pluginId: '5e3fcb59588cc311ef9839feea6382eb' }, type: 'plugins/removePlugin' }, - { payload: '5e3fcb59588cc311ef9839feea6382eb', type: 'legend/removePluginLegend' }, { payload: '5314b9f996e56e67f0dad65e7df8b73b', type: 'plugins/setCurrentDrawerPluginHash', }, + { payload: '5e3fcb59588cc311ef9839feea6382eb', type: 'legend/removePluginLegend' }, ]); }); diff --git a/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.tsx b/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.tsx index c1a4c58af8aac6a90fa55d21fc3334771f361f41..39e12e5bd6dc72e15886011a4691eca508eec595 100644 --- a/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.tsx +++ b/src/components/Map/PluginsDrawer/PluginsTabs/PluginSingleTab/PluginSingleTab.component.tsx @@ -2,8 +2,6 @@ import { useLoadPlugin } from '@/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin'; import { FIRST_ARRAY_ELEMENT, ZERO } from '@/constants/common'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { allActivePluginsSelector } from '@/redux/plugins/plugins.selectors'; import { setCurrentDrawerPluginHash } from '@/redux/plugins/plugins.slice'; import { Button } from '@/shared/Button'; import { Icon } from '@/shared/Icon'; @@ -16,7 +14,6 @@ interface Props { export const PluginSingleTab = ({ plugin }: Props): JSX.Element => { const dispatch = useAppDispatch(); - const allActivePlugins = useAppSelector(allActivePluginsSelector); const { unloadPlugin, isPluginSelected } = useLoadPlugin({ hash: plugin.hash, @@ -30,12 +27,6 @@ export const PluginSingleTab = ({ plugin }: Props): JSX.Element => { const onPluginUnload = (event: React.MouseEvent<HTMLDivElement>): void => { event.stopPropagation(); unloadPlugin(); - - const newAllActivePlugins = allActivePlugins.filter(p => p.hash !== plugin.hash); - const lastActivePlugin = newAllActivePlugins.pop(); - if (lastActivePlugin) { - dispatch(setCurrentDrawerPluginHash(lastActivePlugin.hash)); - } }; return ( diff --git a/src/constants/plugins.ts b/src/constants/plugins.ts index 8da8b37f72573ecdb6c28023a1cac8491cbdb6b8..18ee1c8ee9782b2d4b64f666342d790c1f4281ce 100644 --- a/src/constants/plugins.ts +++ b/src/constants/plugins.ts @@ -1,2 +1,3 @@ export const PLUGINS_CONTENT_ELEMENT_ATTR_NAME = 'data-hash'; export const PLUGINS_CONTENT_ELEMENT_ID = 'plugins'; +export const PLUGIN_HASH_PREFIX_SEPARATOR = '-'; diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts index 885f421215d099663d278f47f5498a04050ba6a3..ecbedd35721534bef5f6d274192873088206a28f 100644 --- a/src/redux/plugins/plugins.reducers.test.ts +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -45,6 +45,7 @@ describe('plugins reducer', () => { pluginName: pluginFixture.name, pluginUrl: pluginFixture.urls[0], pluginVersion: pluginFixture.version, + extendedPluginName: pluginFixture.name, }), ); @@ -65,6 +66,7 @@ describe('plugins reducer', () => { pluginName: pluginFixture.name, pluginUrl: pluginFixture.urls[0], pluginVersion: pluginFixture.version, + extendedPluginName: pluginFixture.name, }), ); @@ -89,6 +91,7 @@ describe('plugins reducer', () => { pluginName: pluginFixture.name, pluginUrl: pluginFixture.urls[0], pluginVersion: pluginFixture.version, + extendedPluginName: pluginFixture.name, }), ); @@ -97,4 +100,47 @@ describe('plugins reducer', () => { expect(data).toEqual({}); expect(pluginsId).toContain(pluginFixture.hash); }); + it('should store loaded plugin with extendedPluginName as a pluginName', async () => { + const extendedPluginName = `${pluginFixture.name} (2)`; + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + + await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + extendedPluginName, + }), + ); + + const { data } = store.getState().plugins.activePlugins; + + expect(data).toEqual({ + [pluginFixture.hash]: { + ...pluginFixture, + name: extendedPluginName, + }, + }); + }); + it('should set loaded plugin as current active plugin in plugins drawer', 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, + extendedPluginName: pluginFixture.name, + }), + ); + + expect(type).toBe('plugins/registerPlugin/fulfilled'); + const { currentPluginHash } = store.getState().plugins.drawer; + + expect(currentPluginHash).toBe(pluginFixture.hash); + }); }); diff --git a/src/redux/plugins/plugins.reducers.ts b/src/redux/plugins/plugins.reducers.ts index ac8ddae548cc3ea8077e39d1dd2ad7fc8a00e40e..4f3736015b7da8c2c064f7c0524231b1f9e59e63 100644 --- a/src/redux/plugins/plugins.reducers.ts +++ b/src/redux/plugins/plugins.reducers.ts @@ -22,6 +22,7 @@ export const registerPluginReducer = (builder: ActionReducerMapBuilder<PluginsSt const { hash } = action.meta.arg; state.activePlugins.data[hash] = action.payload; + state.drawer.currentPluginHash = hash; } }); builder.addCase(registerPlugin.rejected, state => { diff --git a/src/redux/plugins/plugins.selectors.ts b/src/redux/plugins/plugins.selectors.ts index 3c829c642c9bb5807858d9ce99dd8657c1ec7967..b07eb8e9e378243650e431d3495e890fb3969099 100644 --- a/src/redux/plugins/plugins.selectors.ts +++ b/src/redux/plugins/plugins.selectors.ts @@ -1,5 +1,6 @@ import { MinervaPlugin } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; +import { isPluginHashWithPrefix } from '@/utils/plugins/isPluginHashWithPrefix'; import { rootSelector } from '../root/root.selectors'; export const pluginsSelector = createSelector(rootSelector, state => state.plugins); @@ -59,6 +60,15 @@ export const privateActivePluginsSelector = createSelector( }, ); +export const publicActivePluginsSelector = createSelector( + allActivePluginsSelector, + activePlugins => { + return (activePlugins || []).filter( + plugin => plugin.isPublic && isPluginHashWithPrefix(plugin.hash), + ); + }, +); + export const isPluginActiveSelector = createSelector( [activePluginsIdSelector, (_, activePlugin: string): string => activePlugin], (activePlugins, activePlugin) => activePlugins.includes(activePlugin), diff --git a/src/redux/plugins/plugins.thunks.test.ts b/src/redux/plugins/plugins.thunks.test.ts index 7972222c206923167271607d9c4e844b56d55556..8e15c9f8215bea2203a79a5f419fb063fa3f4e85 100644 --- a/src/redux/plugins/plugins.thunks.test.ts +++ b/src/redux/plugins/plugins.thunks.test.ts @@ -7,6 +7,7 @@ import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ONE, TWO } from '@/constants/common'; import { apiPath } from '../apiPath'; import { PluginsState } from './plugins.types'; import pluginsReducer from './plugins.slice'; @@ -28,15 +29,20 @@ describe('plugins - thunks', () => { }); 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); + const hash = 'hash'; + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, { + ...pluginFixture, + hash, + }); + mockedAxiosApiClient.onGet(apiPath.getPlugin('hash')).reply(HttpStatusCode.Ok, { + ...pluginFixture, + hash, + }); mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, ''); await store.dispatch( getInitPlugins({ - pluginsId: [pluginFixture.hash], + pluginsId: [hash], setHashedPlugin: setHashedPluginMock, }), ); @@ -59,5 +65,38 @@ describe('plugins - thunks', () => { expect(setHashedPluginMock).not.toHaveBeenCalled(); }); + it('should allow to load one plugin multiple times with the same data', async () => { + const hash = 'hash'; + const hashWithPrefix = `prefix-${hash}`; + const script = 'function init() {} init()'; + + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, { + ...pluginFixture, + hash, + }); + mockedAxiosApiClient.onGet(apiPath.getPlugin(hash)).reply(HttpStatusCode.Ok, { + ...pluginFixture, + hash, + }); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, script); + + await store.dispatch( + getInitPlugins({ + pluginsId: [hash, hashWithPrefix], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).toHaveBeenCalledTimes(2); + expect(setHashedPluginMock).toHaveBeenNthCalledWith(ONE, { + pluginScript: script, + pluginUrl: pluginFixture.urls[0], + }); + + expect(setHashedPluginMock).toHaveBeenNthCalledWith(TWO, { + pluginScript: script, + pluginUrl: pluginFixture.urls[0], + }); + }); }); }); diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts index 76dc4491488086e5a89990ecd8d4fda2593cedff..9f2108c4973f882ae17268ea73d2ca0e11da30c9 100644 --- a/src/redux/plugins/plugins.thunks.ts +++ b/src/redux/plugins/plugins.thunks.ts @@ -1,4 +1,5 @@ /* eslint-disable no-magic-numbers */ +/* eslint-disable no-param-reassign */ import { pluginSchema } from '@/models/pluginSchema'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import type { MinervaPlugin } from '@/types/models'; @@ -7,6 +8,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; import { getErrorMessage } from '@/utils/getErrorMessage'; import { ThunkConfig } from '@/types/store'; +import { getPluginHashWithoutPrefix } from '@/utils/plugins/getPluginHashWithoutPrefix'; import { apiPath } from '../apiPath'; import { PLUGIN_FETCHING_ALL_ERROR_PREFIX, @@ -20,6 +22,7 @@ type RegisterPlugin = { pluginName: string; pluginVersion: string; isPublic: boolean; + extendedPluginName: string; }; export const registerPlugin = createAsyncThunk< @@ -28,10 +31,15 @@ export const registerPlugin = createAsyncThunk< ThunkConfig >( 'plugins/registerPlugin', - async ({ hash, isPublic, pluginName, pluginUrl, pluginVersion }, { rejectWithValue }) => { + async ( + { hash, isPublic, pluginName, pluginUrl, pluginVersion, extendedPluginName }, + { rejectWithValue }, + ) => { try { + const hashWihtoutPrefix = getPluginHashWithoutPrefix(hash); + const payload = { - hash, + hash: hashWihtoutPrefix, url: pluginUrl, name: pluginName, version: pluginVersion, @@ -49,7 +57,11 @@ export const registerPlugin = createAsyncThunk< const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); if (isDataValid) { - return response.data; + return { + ...response.data, + hash, + name: extendedPluginName, + }; } return undefined; @@ -77,8 +89,11 @@ export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps, ThunkC async ({ pluginsId, setHashedPlugin }, { rejectWithValue }) => { try { /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const pluginId of pluginsId) { - const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(pluginId)); + const hash = getPluginHashWithoutPrefix(pluginId); + + const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(hash)); const isDataValid = validateDataUsingZodSchema(res.data, pluginSchema); diff --git a/src/services/pluginsManager/pluginsManager.test.ts b/src/services/pluginsManager/pluginsManager.test.ts index e7908afce0f63f192d9d070be8c323bfdfbfde1e..8cbf649bd7990a08482f6e2201bbcfaceaf81072 100644 --- a/src/services/pluginsManager/pluginsManager.test.ts +++ b/src/services/pluginsManager/pluginsManager.test.ts @@ -1,13 +1,19 @@ /* eslint-disable no-magic-numbers */ -import { ZERO } from '@/constants/common'; +import { ONE, TWO, ZERO } from '@/constants/common'; import { PLUGINS_CONTENT_ELEMENT_ATTR_NAME, PLUGINS_CONTENT_ELEMENT_ID } from '@/constants/plugins'; import { configurationFixture } from '@/models/fixtures/configurationFixture'; import { store } from '@/redux/store'; +import { registerPlugin } from '@/redux/plugins/plugins.thunks'; import { PluginsManager } from './pluginsManager'; import { configurationMapper } from './pluginsManager.utils'; import { ERROR_PLUGIN_URL_MISMATCH } from './errorMessages'; jest.mock('../../redux/store'); +jest.mock('../../redux/plugins/plugins.thunks'); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('uuidValue'), +})); const createWrapperInDocument = (): HTMLDivElement => { const wrapper = document.createElement('div'); @@ -149,4 +155,196 @@ describe('PluginsManager', () => { }), ).toThrow(ERROR_PLUGIN_URL_MISMATCH); }); + + it('should allow load multiple plugins', () => { + const pluginName = 'TestPlugin'; + const pluginVersion = '1.0.0'; + const pluginUrl = 'https://example.com/test-plugin.js'; + const hash = '128ce10ae1b46ec4bc6d7c07278b5c9e'; + + PluginsManager.activePlugins = {}; + PluginsManager.pluginsOccurrences = {}; + PluginsManager.hashedPlugins = { + [pluginUrl]: hash, + }; + + PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + + expect(registerPlugin).toHaveBeenNthCalledWith( + ONE, + expect.objectContaining({ + isPublic: false, + extendedPluginName: 'TestPlugin', + pluginName: 'TestPlugin', + }), + ); + + expect(registerPlugin).toHaveBeenNthCalledWith( + TWO, + expect.objectContaining({ + isPublic: false, + extendedPluginName: 'TestPlugin (1)', + pluginName: 'TestPlugin', + }), + ); + + expect(registerPlugin).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + isPublic: false, + extendedPluginName: 'TestPlugin (2)', + pluginName: 'TestPlugin', + }), + ); + }); + describe('updatePluginOccurrence', () => { + it('should increment occurrence if hash exists in pluginsOccurrences and extendedHash has prefix', () => { + const hash = 'someHash'; + const extendedHash = 'prefix-someHash'; + + PluginsManager.pluginsOccurrences = { [hash]: 1 }; + const expectedPluginsOccurrences = { [hash]: 2 }; + + PluginsManager.updatePluginOccurrence(hash, extendedHash); + + expect(PluginsManager.pluginsOccurrences).toEqual(expectedPluginsOccurrences); + }); + + it('should initialize occurrence if hash does not exist in pluginsOccurrences', () => { + const hash = 'someHash'; + const extendedHash = 'someHash-suffix'; + + PluginsManager.pluginsOccurrences = {}; + const expectedPluginsOccurrences = { [hash]: ZERO }; + + PluginsManager.updatePluginOccurrence(hash, extendedHash); + + expect(PluginsManager.pluginsOccurrences).toEqual(expectedPluginsOccurrences); + }); + + it('should not modify occurrence if extendedHash does not have prefix', () => { + const hash = 'someHash'; + const extendedHash = 'someHash'; + + PluginsManager.pluginsOccurrences = { [hash]: ONE }; + + PluginsManager.updatePluginOccurrence(hash, extendedHash); + + expect(PluginsManager.pluginsOccurrences).toEqual({ [hash]: ONE }); + }); + }); + describe('getExtendedPluginName', () => { + it('should return pluginName if plugin is not active', () => { + const hash = 'someHash'; + const extendedHash = 'someHash-suffix'; + const pluginName = 'Some Plugin'; + PluginsManager.activePlugins = { + [hash]: [hash], + }; + + const result = PluginsManager.getExtendedPluginName(hash, extendedHash, pluginName); + + expect(result).toBe(pluginName); + }); + + it('should return pluginName without number if hash does not have prefix', () => { + const hash = 'someHash'; + const extendedHash = 'someOtherHash'; + const pluginName = 'Some Plugin'; + + PluginsManager.activePlugins = { + [hash]: [hash, 'hash1'], + }; + + const result = PluginsManager.getExtendedPluginName(hash, extendedHash, pluginName); + + expect(result).toBe(pluginName); + }); + + it('should return pluginName with number if hash has prefix and there is more active plugins than 1', () => { + const hash = 'someHash'; + const extendedHash = 'suffix-someHash'; + const pluginName = 'Some Plugin'; + + PluginsManager.activePlugins = { + [hash]: ['somePluginId1', 'somePluginId2'], + }; + + const result = PluginsManager.getExtendedPluginName(hash, extendedHash, pluginName); + + expect(result).toBe(`${pluginName} (${ONE})`); + }); + }); + describe('getExtendedPluginHash', () => { + beforeEach(() => { + PluginsManager.activePlugins = {}; + }); + + it('should return the original hash if it is not in active plugins', () => { + const hash = 'someHash'; + const expectedExtendedHash = hash; + + const result = PluginsManager.getExtendedPluginHash(hash); + + expect(result).toBe(expectedExtendedHash); + }); + + it('should return hash with prefix if the original hash is in active plugins', () => { + const hash = 'someHash'; + + PluginsManager.activePlugins = { + [hash]: [hash], + }; + + const result = PluginsManager.getExtendedPluginHash(hash); + + expect(result).toBe('a7d06e6d48c9cd8b7726c5d91238408f-someHash'); + }); + }); + describe('unloadActivePlugin', () => { + beforeEach(() => { + PluginsManager.activePlugins = {}; + PluginsManager.pluginsOccurrences = {}; + }); + + it('should remove the hash from activePlugins and set occurrences to zero if the hash exists in activePlugins and its occurrences are zero after removal', () => { + const hash = 'someHash'; + PluginsManager.activePlugins[hash] = [hash]; + PluginsManager.pluginsOccurrences[hash] = 0; + + PluginsManager.unloadActivePlugin(hash); + + expect(PluginsManager.activePlugins[hash]).toEqual([]); + expect(PluginsManager.pluginsOccurrences[hash]).toBe(0); + }); + + it('should remove the hash from activePlugins and not modify occurrences if some hash exists in activePlugins', () => { + const hash = 'someHash'; + const hashWithPrefix = `xxx-${hash}`; + const occurrences = 2; + PluginsManager.activePlugins[hash] = [hash, hashWithPrefix]; + PluginsManager.pluginsOccurrences[hash] = occurrences; + + PluginsManager.unloadActivePlugin(hashWithPrefix); + + expect(PluginsManager.activePlugins[hash]).toEqual([hash]); + expect(PluginsManager.pluginsOccurrences[hash]).toBe(occurrences); + }); + + it('should not modify activePlugins or occurrences if the hash does not exist in activePlugins', () => { + const hash = 'hash'; + + const hashWithPrefix = `xxx-aaa`; + const occurrences = 1; + PluginsManager.activePlugins[hash] = [hash]; + PluginsManager.pluginsOccurrences[hash] = occurrences; + + PluginsManager.unloadActivePlugin(hashWithPrefix); + + expect(PluginsManager.activePlugins[hash]).toEqual([hash]); + expect(PluginsManager.pluginsOccurrences[hash]).toEqual(occurrences); + }); + }); }); diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts index 03d06d80cb1cf4b086937f1ad647d7f354a1b2c2..e57eb85a342efc87b10029936a2c447a4ba57881 100644 --- a/src/services/pluginsManager/pluginsManager.ts +++ b/src/services/pluginsManager/pluginsManager.ts @@ -1,7 +1,12 @@ +/* eslint-disable no-magic-numbers */ import { PLUGINS_CONTENT_ELEMENT_ATTR_NAME, PLUGINS_CONTENT_ELEMENT_ID } from '@/constants/plugins'; import { registerPlugin } from '@/redux/plugins/plugins.thunks'; import { store } from '@/redux/store'; import md5 from 'crypto-js/md5'; +import { v4 as uuidv4 } from 'uuid'; +import { isPluginHashWithPrefix } from '@/utils/plugins/isPluginHashWithPrefix'; +import { getPluginHashWithoutPrefix } from '@/utils/plugins/getPluginHashWithoutPrefix'; +import { ONE, ZERO } from '@/constants/common'; import { bioEntitiesMethods } from './bioEntities'; import { getModels } from './map/models/getModels'; import { openMap } from './map/openMap'; @@ -46,6 +51,22 @@ export const PluginsManager: PluginsManagerType = { return hash; }, + activePlugins: {}, + pluginsOccurrences: {}, + + unloadActivePlugin: hash => { + const hashWihtoutPrefix = getPluginHashWithoutPrefix(hash); + + PluginsManager.activePlugins[hashWihtoutPrefix] = + PluginsManager.activePlugins[hashWihtoutPrefix]?.filter(el => el !== hash) || []; + + if ( + PluginsManager.activePlugins[hashWihtoutPrefix].length === ZERO && + hashWihtoutPrefix in PluginsManager.pluginsOccurrences + ) { + PluginsManager.pluginsOccurrences[hashWihtoutPrefix] = ZERO; + } + }, init() { window.minerva = { plugins: { @@ -112,36 +133,70 @@ export const PluginsManager: PluginsManagerType = { return unsubscribe; }, + getExtendedPluginHash(hash: string) { + let extendedHash = hash; + + if (!(hash in PluginsManager.activePlugins)) PluginsManager.activePlugins[hash] = []; + + if (PluginsManager.activePlugins[hash].includes(hash)) { + const prefix = md5(uuidv4()).toString(); + extendedHash = `${prefix}-${hash}`; + } + + PluginsManager.activePlugins[hash].push(extendedHash); + return extendedHash; + }, + getExtendedPluginName(hash, extendedHash, pluginName) { + return PluginsManager.activePlugins[hash].length === 1 || !isPluginHashWithPrefix(extendedHash) + ? pluginName + : `${pluginName} (${PluginsManager.pluginsOccurrences[hash]})`; + }, + updatePluginOccurrence(hash, extendedHash) { + if (hash in PluginsManager.pluginsOccurrences && isPluginHashWithPrefix(extendedHash)) { + PluginsManager.pluginsOccurrences[hash] += ONE; + } else if (!(hash in PluginsManager.pluginsOccurrences)) { + PluginsManager.pluginsOccurrences[hash] = ZERO; + } + }, registerPlugin({ pluginName, pluginVersion, pluginUrl }) { const hash = PluginsManager.hashedPlugins[pluginUrl]; + const extendedHash = PluginsManager.getExtendedPluginHash(hash); + + PluginsManager.updatePluginOccurrence(hash, extendedHash); + if (!hash) { throw new Error(ERROR_PLUGIN_URL_MISMATCH); } + const extendedPluginName = PluginsManager.getExtendedPluginName(hash, extendedHash, pluginName); + store.dispatch( registerPlugin({ - hash, + hash: extendedHash, isPublic: false, + extendedPluginName, pluginName, pluginUrl, pluginVersion, }), ); - const element = PluginsManager.createAndGetPluginContent({ hash }); + const element = PluginsManager.createAndGetPluginContent({ + hash: extendedHash, + }); return { element, events: { - addListener: PluginsEventBus.addListener.bind(this, hash, pluginName), - removeListener: PluginsEventBus.removeListener.bind(this, hash), - removeAllListeners: PluginsEventBus.removeAllListeners.bind(this, hash), + addListener: PluginsEventBus.addListener.bind(this, extendedHash, pluginName), + removeListener: PluginsEventBus.removeListener.bind(this, extendedHash), + removeAllListeners: PluginsEventBus.removeAllListeners.bind(this, extendedHash), }, legend: { - setLegend: setLegend.bind(this, hash), - removeLegend: removeLegend.bind(this, hash), + setLegend: setLegend.bind(this, extendedHash), + removeLegend: removeLegend.bind(this, extendedHash), }, }; }, diff --git a/src/services/pluginsManager/pluginsManager.types.ts b/src/services/pluginsManager/pluginsManager.types.ts index 76a380f182e47859ba026455457c86e7c5fd2fe0..2d959cf2ed554311a313fd7ef8b7f2ffd4cfd57c 100644 --- a/src/services/pluginsManager/pluginsManager.types.ts +++ b/src/services/pluginsManager/pluginsManager.types.ts @@ -21,4 +21,14 @@ export type PluginsManagerType = { }; createAndGetPluginContent(plugin: Pick<MinervaPlugin, 'hash'>): HTMLDivElement; removePluginContent(plugin: Pick<MinervaPlugin, 'hash'>): void; + activePlugins: { + [pluginId: string]: string[]; + }; + pluginsOccurrences: { + [pluginId: string]: number; + }; + unloadActivePlugin(hash: string): void; + getExtendedPluginHash(hash: string): string; + updatePluginOccurrence(hash: string, extendedHash: string): void; + getExtendedPluginName(hash: string, extendedHash: string, pluginName: string): string; }; diff --git a/src/utils/plugins/getPluginHashWithoutPrefix.test.ts b/src/utils/plugins/getPluginHashWithoutPrefix.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..48ea3bf390ebac981d05de8701b036f822d205f2 --- /dev/null +++ b/src/utils/plugins/getPluginHashWithoutPrefix.test.ts @@ -0,0 +1,16 @@ +import { getPluginHashWithoutPrefix } from './getPluginHashWithoutPrefix'; + +describe('getPluginHashWithoutPrefix', () => { + it('should return hash without prefix if hash contains the prefix separator', () => { + const hash = 'prefix-hash'; + const expectedResult = 'hash'; + const result = getPluginHashWithoutPrefix(hash); + expect(result).toBe(expectedResult); + }); + + it('should return the same hash if hash does not contain the prefix separator', () => { + const hash = 'hash'; + const result = getPluginHashWithoutPrefix(hash); + expect(result).toBe(hash); + }); +}); diff --git a/src/utils/plugins/getPluginHashWithoutPrefix.ts b/src/utils/plugins/getPluginHashWithoutPrefix.ts new file mode 100644 index 0000000000000000000000000000000000000000..600e3ad2ab9b2cdb6ff95719554d45efaa0dcdb1 --- /dev/null +++ b/src/utils/plugins/getPluginHashWithoutPrefix.ts @@ -0,0 +1,13 @@ +import { ONE } from '@/constants/common'; +import { PLUGIN_HASH_PREFIX_SEPARATOR } from '@/constants/plugins'; +import { isPluginHashWithPrefix } from './isPluginHashWithPrefix'; + +export const getPluginHashWithoutPrefix = (hash: string): string => { + if (isPluginHashWithPrefix(hash)) { + const hashWihtoutPrefix = hash.substring(hash.lastIndexOf(PLUGIN_HASH_PREFIX_SEPARATOR) + ONE); + + return hashWihtoutPrefix; + } + + return hash; +}; diff --git a/src/utils/plugins/isPluginHashWithPrefix.test.ts b/src/utils/plugins/isPluginHashWithPrefix.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfd77870ab8988b3f16c1859cac9dc97abf0bcfb --- /dev/null +++ b/src/utils/plugins/isPluginHashWithPrefix.test.ts @@ -0,0 +1,15 @@ +import { isPluginHashWithPrefix } from './isPluginHashWithPrefix'; + +describe('isPluginHashWithPrefix', () => { + it('should return true if the hash contains the prefix separator', () => { + const hash = 'someHashprefix-hash'; + const result = isPluginHashWithPrefix(hash); + expect(result).toBe(true); + }); + + it('should return false if the hash does not contain the prefix separator', () => { + const hash = 'someHash'; + const result = isPluginHashWithPrefix(hash); + expect(result).toBe(false); + }); +}); diff --git a/src/utils/plugins/isPluginHashWithPrefix.ts b/src/utils/plugins/isPluginHashWithPrefix.ts new file mode 100644 index 0000000000000000000000000000000000000000..5749915be5a5a3a9c33bef5aa24d59bd1f0c12e1 --- /dev/null +++ b/src/utils/plugins/isPluginHashWithPrefix.ts @@ -0,0 +1,5 @@ +import { PLUGIN_HASH_PREFIX_SEPARATOR } from '@/constants/plugins'; + +export const isPluginHashWithPrefix = (hash: string): boolean => { + return hash.includes(PLUGIN_HASH_PREFIX_SEPARATOR); +};