Skip to content
Snippets Groups Projects
Commit d3463cdd authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(plugins): adjust available plugins

parent ba7f1766
No related branches found
No related tags found
3 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!122feat: add main plugins drawer with tabs (MIN-232),!116feat(plugins): Plugin loading management (MIN-233)
Showing
with 277 additions and 246 deletions
import { publicPluginsListSelector } from '@/redux/plugins/plugins.selectors';
import {
privateActivePluginsSelector,
publicPluginsListSelector,
} from '@/redux/plugins/plugins.selectors';
import { DrawerHeading } from '@/shared/DrawerHeading';
import { useSelector } from 'react-redux';
import { LoadPlugin } from './LoadPlugin';
......@@ -6,12 +9,17 @@ import { LoadPluginFromUrl } from './LoadPluginFromUrl';
export const AvailablePluginsDrawer = (): JSX.Element => {
const publicPlugins = useSelector(publicPluginsListSelector);
const privateActivePlugins = useSelector(privateActivePluginsSelector);
return (
<div className="h-full max-h-full" data-testid="available-plugins-drawer">
<DrawerHeading title="Available plugins" />
<div className="flex flex-col gap-6 p-6">
<LoadPluginFromUrl />
{privateActivePlugins.map(plugin => (
<LoadPlugin key={plugin.hash} plugin={plugin} />
))}
{publicPlugins.map(plugin => (
<LoadPlugin key={plugin.hash} plugin={plugin} />
))}
......
/* 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({});
});
});
});
/* 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">
......@@ -16,9 +19,11 @@ export const LoadPlugin = ({ plugin }: Props): JSX.Element => {
<Button
variantStyles="secondary"
className="h-10 self-end rounded-e rounded-s"
onClick={handleLoadPlugin}
onClick={togglePlugin}
data-testid="toggle-plugin"
disabled={isPluginLoading}
>
Load
{isPluginActive ? 'Unload' : 'Load'}
</Button>
</div>
);
......
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,
};
};
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 { 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', () => {
......@@ -28,5 +51,31 @@ 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();
await 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();
await act(() => {
button?.click();
});
expect(button).toBeDisabled();
});
});
});
import { Button } from '@/shared/Button';
import { useLoadPlugin } from '../hooks/useLoadPlugin';
import { useLoadPluginFromUrl } from './hooks/useLoadPluginFromUrl';
export const LoadPluginFromUrl = (): JSX.Element => {
const { handleChangePluginUrl, handleLoadPlugin, isPending, pluginUrl } = useLoadPlugin();
const { handleChangePluginUrl, handleLoadPlugin, isPending, pluginUrl } = useLoadPluginFromUrl();
return (
<div className="flex w-full">
......@@ -21,6 +21,7 @@ export const LoadPluginFromUrl = (): JSX.Element => {
className="h-10 self-end rounded-e rounded-s"
onClick={handleLoadPlugin}
disabled={isPending}
data-testid="load-plugin-button"
>
Load
</Button>
......
......@@ -9,30 +9,25 @@ type UseLoadPluginReturnType = {
pluginUrl: string;
};
export const useLoadPlugin = (): UseLoadPluginReturnType => {
export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => {
const [pluginUrl, setPluginUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const isPending = isLoading || !pluginUrl;
const isPending = !pluginUrl;
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);
PluginsManager.setHashedPlugin({
pluginUrl,
pluginScript,
});
loadPlugin();
setPluginUrl('');
} finally {
setIsLoading(false);
}
const response = await axios(pluginUrl);
const pluginScript = response.data;
/* eslint-disable no-new-func */
const loadPlugin = new Function(pluginScript);
PluginsManager.setHashedPlugin({
pluginUrl,
pluginScript,
});
loadPlugin();
setPluginUrl('');
};
const handleChangePluginUrl = (event: ChangeEvent<HTMLInputElement>): void => {
setPluginUrl(event.target.value);
......
import { Button } from '@/shared/Button';
import { Input } from '@/shared/Input';
import { useLoadPlugin } from '../../AvailablePluginsDrawer/hooks/useLoadPlugin';
export const LoadPluginInput = (): React.ReactNode => {
const { handleChangePluginUrl, handleLoadPlugin, isPending, pluginUrl } = useLoadPlugin();
return (
<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={isPending}
>
Load
</Button>
</div>
</label>
);
};
export { LoadPluginInput } from './LoadPluginInput.component';
import { DrawerHeading } from '@/shared/DrawerHeading';
import React from 'react';
import { LoadPluginInput } from './LoadPluginInput';
import { PluginsList } from './PluginsList';
export const PluginsDrawer = (): React.ReactNode => (
<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">
<LoadPluginInput />
<PluginsList />
</div>
</div>
);
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { activePluginsSelector } from '@/redux/plugins/plugins.selector';
import { removePlugin } from '@/redux/plugins/plugins.slice';
import { Button } from '@/shared/Button';
export const PluginsList = (): React.ReactNode => {
const activePlugins = useAppSelector(activePluginsSelector);
const dispatch = useAppDispatch();
const handleUnloadPlugin = (pluginId: string): void => {
dispatch(removePlugin({ pluginId }));
};
return (
<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>
);
};
export { PluginsList } from './PluginsList.component';
export { PluginsDrawer } from './PluginsDrawer.component';
import { DEFAULT_ERROR } from '@/constants/errors';
import { ActivePlugins, PluginsList, PluginsState } from './plugins.types';
export const PLUGINS_INITIAL_STATE__ACTIVE_PLUGINS_MOCK: ActivePlugins = {
export const PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK: ActivePlugins = {
data: {},
pluginsId: [],
};
......@@ -14,5 +14,5 @@ export const PLUGINS_INITIAL_STATE_LIST_MOCK: PluginsList = {
export const PLUGINS_INITIAL_STATE_MOCK: PluginsState = {
list: PLUGINS_INITIAL_STATE_LIST_MOCK,
activePlugins: PLUGINS_INITIAL_STATE__ACTIVE_PLUGINS_MOCK,
activePlugins: PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK,
};
......@@ -9,7 +9,7 @@ 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.thunk';
import { registerPlugin } from './plugins.thunks';
import { PLUGINS_INITIAL_STATE_MOCK } from './plugins.mock';
const mockedAxiosClient = mockNetworkResponse();
......
import type { ActionReducerMapBuilder } from '@reduxjs/toolkit';
import type { PluginsState, RemovePluginAction } from './plugins.types';
import { registerPlugin, getAllPlugins } from './plugins.thunk';
import { registerPlugin, getAllPlugins } from './plugins.thunks';
export const removePluginReducer = (state: PluginsState, action: RemovePluginAction): void => {
const { pluginId } = action.payload;
......
import { createSelector } from '@reduxjs/toolkit';
import { MinervaPlugin } from '@/types/models';
import { rootSelector } from '../root/root.selectors';
export const pluginsSelector = createSelector(rootSelector, state => state.plugins);
export const activePluginsIdSelector = createSelector(
pluginsSelector,
state => state.activePlugins.pluginsId,
);
export const pluginsDataSelector = createSelector(
pluginsSelector,
plugins => plugins.activePlugins.data,
);
export const activePluginsSelector = createSelector(
pluginsDataSelector,
activePluginsIdSelector,
(data, pluginsId) => {
const result: MinervaPlugin[] = [];
pluginsId.forEach(pluginId => {
const element = data[pluginId];
if (element) {
result.push(element);
}
});
return result;
},
);
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,
);
/* 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 { 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[]> => {
const response = await axiosInstance.get<MinervaPlugin[]>(apiPath.getAllPlugins());
const isDataValid = validateDataUsingZodSchema(response.data, z.array(pluginSchema));
return isDataValid ? response.data : [];
},
);
......@@ -10,7 +10,7 @@ import {
import { apiPath } from '../apiPath';
import { PluginsState } from './plugins.types';
import pluginsReducer from './plugins.slice';
import { getInitPlugins } from './plugins.thunk';
import { getInitPlugins } from './plugins.thunks';
const mockedAxiosApiClient = mockNetworkResponse();
const mockedAxiosClient = new MockAdapter(axios);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment