diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..38a0345e9a3f3ffd18ea6eb6cb25a0d2baf6eafc --- /dev/null +++ b/index.d.ts @@ -0,0 +1,29 @@ +import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager'; + +type Plugin = { + pluginName: string; + pluginVersion: string; + pluginUrl: string; +}; + +type RegisterPlugin = ({ pluginName, pluginVersion, pluginUrl }: Plugin) => { + element: HTMLDivElement; +}; + +declare global { + interface Window { + minerva: { + configuration?: MinervaConfiguration; + plugins: { + registerPlugin: RegisterPlugin; + setHashedPlugin({ + pluginUrl, + pluginScript, + }: { + pluginUrl: string; + pluginScript: string; + }): void; + }; + }; + } +} diff --git a/package-lock.json b/package-lock.json index dabf49dce6380c53f944390a58b1d5a32e3ba10b..ed31a761353363925aaa2032ae636640ad3e54f2 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", @@ -43,6 +44,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", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", @@ -2230,6 +2232,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", @@ -4325,6 +4333,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", @@ -15572,6 +15585,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", @@ -17098,6 +17117,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 4fb3ac58884eefadf31d01b6ad6a9b331d450875..e36be1b84069f4e441b02d9d2be37fd16eb797d9 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", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", diff --git a/pages/_app.tsx b/pages/_app.tsx index d767f29ca9bff1e622a7e7d4fd8723c6ed9b3050..6aaa491fc0284e752eb320ecb1cf72d376013581 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -7,5 +7,4 @@ const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => ( <Component {...pageProps} /> </AppWrapper> ); - export default MyApp; diff --git a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx index 8bb19a16424e70d0c3a0dba4efc6d302308c80d0..9dfc18639e19217d7fc8d00c5c6e190a45aa8c11 100644 --- a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx +++ b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx @@ -51,6 +51,9 @@ export const MapNavigation = (): JSX.Element => { )} </Button> ))} + <div className="fixed bottom-0 right-0 top-[104px] w-96 bg-white"> + <div id="plugins" /> + </div> </div> ); }; diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 098c4f989ec233798af6de0addfbb28fca84b02e..4403fe9b6c719c971445feb36dafcd0d55f60329 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -9,6 +9,7 @@ import { OverlaysDrawer } from './OverlaysDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; import { ExportDrawer } from './ExportDrawer'; import { ProjectInfoDrawer } from './ProjectInfoDrawer'; +import { PluginsDrawer } from './PluginDrawer'; export const Drawer = (): JSX.Element => { const { isOpen, drawerName } = useAppSelector(drawerSelector); @@ -28,6 +29,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} {isOpen && drawerName === 'project-info' && <ProjectInfoDrawer />} {isOpen && drawerName === 'export' && <ExportDrawer />} + {isOpen && drawerName === 'plugins' && <PluginsDrawer />} </div> ); }; diff --git a/src/components/Map/Drawer/PluginDrawer/PluginsDrawer.component.tsx b/src/components/Map/Drawer/PluginDrawer/PluginsDrawer.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb01c303a137ab0e849e708734c3bbb6fa007cc0 --- /dev/null +++ b/src/components/Map/Drawer/PluginDrawer/PluginsDrawer.component.tsx @@ -0,0 +1,84 @@ +import { Button } from '@/shared/Button'; +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { Input } from '@/shared/Input'; +import axios from 'axios'; +import React, { ChangeEvent, useState } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { activePluginsSelector } from '@/redux/plugins/plugins.selector'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { removePlugin } from '@/redux/plugins/plugins.slice'; + +export const PluginsDrawer = (): React.ReactNode => { + const [pluginUrl, setPluginUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const activePlugins = useAppSelector(activePluginsSelector); + const dispatch = useAppDispatch(); + + const handleLoadPlugin = async (): Promise<void> => { + try { + setIsLoading(true); + const res = await axios(pluginUrl); + const pluginScript = res.data; + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + window.minerva.plugins.setHashedPlugin({ + pluginUrl, + pluginScript, + }); + loadPlugin(); + setPluginUrl(''); + } finally { + setIsLoading(false); + } + }; + + const handleUnloadPlugin = (pluginId: string): void => { + dispatch(removePlugin({ pluginId })); + }; + + const handleChangePluginUrl = (event: ChangeEvent<HTMLInputElement>): void => { + setPluginUrl(event.target.value); + }; + + return ( + <div data-testid="available-plugins-drawer" className="h-full max-h-full"> + <DrawerHeading title="Available plugins" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto p-6"> + <label> + URL: + <div className="relative mt-2.5"> + <Input + className="h-10 rounded-r-md pr-[70px]" + value={pluginUrl} + onChange={handleChangePluginUrl} + /> + <Button + className="absolute inset-y-0 right-0 w-[60px] justify-center text-xs font-medium text-primary-500 ring-primary-500 hover:ring-primary-500 disabled:text-primary-500 disabled:ring-primary-500" + variantStyles="ghost" + onClick={handleLoadPlugin} + disabled={isLoading || !pluginUrl} + > + Load + </Button> + </div> + </label> + <ul className="mt-8 flex w-full flex-col gap-y-8"> + {activePlugins.map(plugin => ( + <li key={plugin.hash} className="flex w-full items-center justify-between"> + <span className="text-sm"> + {plugin.name} ({plugin.version}) + </span> + <Button + variantStyles="ghost" + onClick={() => handleUnloadPlugin(plugin.hash)} + className="h-10 w-[60px] justify-center text-xs font-medium text-primary-500 ring-primary-500 hover:ring-primary-500" + > + Unload + </Button> + </li> + ))} + </ul> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/PluginDrawer/index.ts b/src/components/Map/Drawer/PluginDrawer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8858fdbe0d2b7c33f6ce041fbf382b887d4e4281 --- /dev/null +++ b/src/components/Map/Drawer/PluginDrawer/index.ts @@ -0,0 +1 @@ +export { PluginsDrawer } from './PluginsDrawer.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/pluginSchema.ts b/src/models/pluginSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0204b6f05d9e797c3cbad124eb063222bcc3e6f3 --- /dev/null +++ b/src/models/pluginSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const pluginSchema = z.object({ + hash: z.string(), + name: z.string(), + version: z.string(), + isPublic: z.boolean(), + isDefault: z.boolean(), + urls: z.array(z.string()), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 212660e5ee66c695f389fd6c49b7168b6ec78ecb..1638e3328327a0791d63f174dea299d029ae0d3a 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -63,4 +63,6 @@ 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}/`, }; diff --git a/src/redux/plugins/overlays.mock.ts b/src/redux/plugins/overlays.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..63cae1392973ceefc6234e54248670450dbb1137 --- /dev/null +++ b/src/redux/plugins/overlays.mock.ts @@ -0,0 +1,6 @@ +import { PluginsState } from './plugins.types'; + +export const PLUGINS_INITIAL_STATE_MOCK: PluginsState = { + data: {}, + pluginsId: [], +}; diff --git a/src/redux/plugins/plugins.reducers.ts b/src/redux/plugins/plugins.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fa5bbf52332bf3b3ff5861261562fbe89d9a6e7 --- /dev/null +++ b/src/redux/plugins/plugins.reducers.ts @@ -0,0 +1,23 @@ +import type { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import type { PluginsState, RemovePluginAction } from './plugins.types'; +import { registerPlugin } from './plugins.thunk'; + +export const removePluginReducer = (state: PluginsState, action: RemovePluginAction): void => { + const { pluginId } = action.payload; + state.pluginsId = state.pluginsId.filter(id => id !== pluginId); + delete state.data[pluginId]; +}; + +export const registerPluginReducer = (builder: ActionReducerMapBuilder<PluginsState>): void => { + builder.addCase(registerPlugin.pending, (state, action) => { + const { hash } = action.meta.arg; + state.pluginsId.push(hash); + }); + builder.addCase(registerPlugin.fulfilled, (state, action) => { + if (action.payload) { + const { hash } = action.meta.arg; + + state.data[hash] = action.payload; + } + }); +}; diff --git a/src/redux/plugins/plugins.selector.ts b/src/redux/plugins/plugins.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dc1a677d88ec5010611811e8a031a318e22252f --- /dev/null +++ b/src/redux/plugins/plugins.selector.ts @@ -0,0 +1,26 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { Plugin } from '@/types/models'; +import { rootSelector } from '../root/root.selectors'; + +export const pluginsSelector = createSelector(rootSelector, state => state.plugins); + +export const activePluginsIdSelector = createSelector(pluginsSelector, state => state.pluginsId); + +export const pluginsDataSelector = createSelector(pluginsSelector, plugins => plugins.data); + +export const activePluginsSelector = createSelector( + pluginsDataSelector, + activePluginsIdSelector, + (data, pluginsId) => { + const result: Plugin[] = []; + + pluginsId.forEach(pluginId => { + const element = data[pluginId]; + if (element) { + result.push(element); + } + }); + + return result; + }, +); diff --git a/src/redux/plugins/plugins.slice.ts b/src/redux/plugins/plugins.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..be87d49638c587e2fafd19bc709daad157c72476 --- /dev/null +++ b/src/redux/plugins/plugins.slice.ts @@ -0,0 +1,23 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PluginsState } from './plugins.types'; +import { registerPluginReducer, removePluginReducer } from './plugins.reducers'; + +const initialState: PluginsState = { + pluginsId: [], + data: {}, +}; + +export const pluginsSlice = createSlice({ + name: 'plugins', + initialState, + reducers: { + removePlugin: removePluginReducer, + }, + extraReducers: builder => { + registerPluginReducer(builder); + }, +}); + +export const { removePlugin } = pluginsSlice.actions; + +export default pluginsSlice.reducer; diff --git a/src/redux/plugins/plugins.thunk.ts b/src/redux/plugins/plugins.thunk.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fca4a2f8c2de28b0b381623f043a87373c99173 --- /dev/null +++ b/src/redux/plugins/plugins.thunk.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-await-in-loop */ +/* 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 { Plugin } from '@/types/models'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +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<Plugin | undefined> => { + const payload = { + hash, + url: pluginUrl, + name: pluginName, + version: pluginVersion, + isPublic: isPublic.toString(), + } as const; + + const response = await axiosInstance.post<Plugin>( + apiPath.registerPluign(), + new URLSearchParams(payload), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); + + if (isDataValid) { + return response.data; + } + + return undefined; + }, +); + +type GetInitPluginsProps = { pluginsId: string[] }; + +export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps>( + 'plugins/getInitPlugins', + async ({ pluginsId }): Promise<void> => { + /* eslint-disable no-restricted-syntax */ + for (const pluginId of pluginsId) { + const res = await axiosInstance<Plugin>(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; + window.minerva.plugins.setHashedPlugin({ pluginUrl: urls[0], pluginScript }); + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + loadPlugin(); + } + } + }, +); diff --git a/src/redux/plugins/plugins.types.ts b/src/redux/plugins/plugins.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..903fe01c45c8d564cf1ebaf3c286c8b159db3e9b --- /dev/null +++ b/src/redux/plugins/plugins.types.ts @@ -0,0 +1,12 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { Plugin } from '@/types/models'; + +export type PluginsState = { + pluginsId: string[]; + data: { + [pluginId: string]: Plugin | undefined; + }; +}; + +export type RemovePluginPayload = { pluginId: string }; +export type RemovePluginAction = PayloadAction<RemovePluginPayload>; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index c4ac19274381cdffa49913eff2a4e40ed79960e7..f6adbdc589aa5b1f696631b85fa2e0ac1bbb3889 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -20,6 +20,7 @@ import { getSessionValid } from '../user/user.thunks'; import { getInitOverlays } from '../overlayBioEntity/overlayBioEntity.thunk'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { getStatisticsById } from '../statistics/statistics.thunks'; +import { getInitPlugins } from '../plugins/plugins.thunk'; interface InitializeAppParams { queryData: QueryData; @@ -31,6 +32,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)), @@ -70,4 +72,12 @@ export const fetchInitialAppData = createAsyncThunk< if (queryData.overlaysId) { dispatch(getInitOverlays({ overlaysId: queryData.overlaysId })); } + + if (queryData.pluginsId) { + dispatch( + getInitPlugins({ + pluginsId: queryData.pluginsId, + }), + ); + } }); diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts index 3088b0dacb0ae0051fb11a60a658af927aff4667..3fbc3a8b939e7ff6aa95db3cf5b007aa3547fcbd 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.selector'; 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/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index c236df284e59fbdba9e80a306213f18410e9cc67..e7b83da91015af644a20e7d39c949ec5580a5049 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -20,6 +20,7 @@ import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; import { COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK } from '../compartmentPathways/compartmentPathways.mock'; import { EXPORT_INITIAL_STATE_MOCK } from '../export/export.mock'; +import { PLUGINS_INITIAL_STATE_MOCK } from '../plugins/overlays.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -43,4 +44,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { statistics: STATISTICS_STATE_INITIAL_MOCK, compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK, export: EXPORT_INITIAL_STATE_MOCK, + plugins: PLUGINS_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index a945a7518a65a15740f4922027fc4eb864b3d1cd..a515461172f3468e39668a4b5606a4df24528938 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -27,6 +27,7 @@ import { mapListenerMiddleware } from './map/middleware/map.middleware'; import statisticsReducer from './statistics/statistics.slice'; import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; import exportReducer from './export/export.slice'; +import pluginsReducer from './plugins/plugins.slice'; export const reducers = { search: searchReducer, @@ -50,6 +51,7 @@ export const reducers = { statistics: statisticsReducer, compartmentPathways: compartmentPathwaysReducer, export: exportReducer, + plugins: pluginsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; 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.ts b/src/services/pluginsManager/pluginsManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..620354deb5292401b119aef8d203249f61f9fde4 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.ts @@ -0,0 +1,59 @@ +import md5 from 'crypto-js/md5'; +import { store } from '@/redux/store'; +import { registerPlugin } from '@/redux/plugins/plugins.thunk'; +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; + }, + init() { + window.minerva = { + plugins: { + registerPlugin: PluginsManager.registerPlugin, + setHashedPlugin: PluginsManager.setHashedPlugin, + }, + }; + + 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, + }), + ); + + 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..00e247a7e7f3483875edd710a157df2ee740928d --- /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 }): void; + 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/models.ts b/src/types/models.ts index e48801f3cd773468ab419c4d233424833c4b3126..d46b347f7d6e3fa6c0f12b87a9949d3f94d82271 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -30,6 +30,7 @@ import { overviewImageLinkModel, } from '@/models/overviewImageLink'; import { overviewImageView } from '@/models/overviewImageView'; +import { pluginSchema } from '@/models/pluginSchema'; import { projectSchema } from '@/models/projectSchema'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; @@ -76,3 +77,4 @@ export type CompartmentPathway = z.infer<typeof compartmentPathwaySchema>; export type CompartmentPathwayDetails = z.infer<typeof compartmentPathwayDetailsSchema>; export type ExportNetwork = z.infer<typeof exportNetworkchema>; export type ExportElements = z.infer<typeof exportElementsSchema>; +export type Plugin = z.infer<typeof pluginSchema>; 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..b25ab80d963ee3b8e6fa12ba1d4bc3c9014f041b 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.ts @@ -10,11 +10,10 @@ export const useReduxBusQueryManager = (): void => { const isDataLoaded = useSelector(initDataAndMapLoadingFinished); const handleChangeQuery = useCallback( - () => - router.replace( + () => { + return router.replace( { query: { - ...router.query, ...queryData, }, }, @@ -22,7 +21,8 @@ export const useReduxBusQueryManager = (): void => { { shallow: true, }, - ), + ); + }, // router is not an stable reference // eslint-disable-next-line react-hooks/exhaustive-deps [queryData],