mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
PUI Plugin Panels (#7470)
* Adds basic API endpoint for requesting plugin panels
* Split PanelType out into own file
* Placeholder for a plugin panel loaded dynamically
* Add some dummy data for the plugin panels
* Example of plugin panel selection based on page
* Expose some global window attributes
* Add new setting
* Disable panel return if plugin integration is not enabled
* Update hook to auto-magically load plugin panels
* Allow custom panel integration for more panel groups
* Remove debug call
* Tweak query return data
* async fn
* Adds <PluginPanel> component for handling panel render
* Cleanup
* Prevent API requests before instance ID is known
* Pass instance data through
* Framework for a sample plugin which implements custom panels
* offload custom panels to sample plugin
* Load raw HTML content
* Expand custom panel rendering demo
* Adjust API endpoints
* Add function to clear out static files which do not match installed plugin(s)
* Update static files when installing plugins from file
* Update static files when installing or uninstalling a plugin
* Update static files on config change
* Pass more information through to plugin panels
* Prepend hostname to plugin source
* Pass instance detail through
* Cleanup code for passing data through to plugin panels
- Define interface type
- Shorten variable names
* Update docs requirements
* Revert "Update docs requirements"
This reverts commit 63a06d97f5
.
* Add placeholder for documentation
* Fix imports
* Add a broken panel which tries to load a non-existent javascript file
* Render error message if plugin does not load correctly
* Only allow superuser to perform plugin actions
* Code cleanup
* Add "dynamic" contnt - javascript file - to example plugin
* Remove default values
* Cleanup unused code
* PanelGroup updates
* Cleanup hooks for changing panel state
* More work needed...
* Code cleanup
* More updates / refactoring
- Allow dynamic hiding of a particular panel
- Pass target ref as positional argument
- Better handling of async calls
* Documentation
* Bump API version
* Provide theme object to plugin context
* Adjust sample plugin
* Docs updates
* Fix includefile call in docs
* Improve type annotation
* Cleanup
* Enable plugin panels for "purchasing index" and "sales index" pages
* Fix for plugin query check
* Improvements to panel selection
- Code refactor / cleanup
- Ensure that a valid panel is always displayed
- Allow plugin panels to persist, even after reload
* Playwright test fixes
* Update src/frontend/src/hooks/UsePluginPanels.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Update src/frontend/src/components/plugins/PluginPanel.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Update src/frontend/src/components/plugins/PluginContext.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Fix context
* Add more context data
* Docs updates
* Reimplement local state
* Fix mkdocs.yml
* Expose 'colorScheme' to plugin context
* Define CustomPanel type definition
* Add unit testing for user interface plugins
* Add front-end tests for plugin panels
* Add new setting to plugin_settings_keys
* Adds helper function for annotating build line allocations
* Improve query efficiency
- Especially around unit testing
- Ensure all settings are generated
- Do not auto-create settings during registry load
* Improve query efficiency for build order operations
* Reduce max query count for specific test
* Revert query count limit
* playwright test updates
---------
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
This commit is contained in:
@ -38,7 +38,8 @@ export default defineConfig({
|
||||
{
|
||||
command: 'invoke dev.server -a 127.0.0.1:8000',
|
||||
env: {
|
||||
INVENTREE_DEBUG: 'True'
|
||||
INVENTREE_DEBUG: 'True',
|
||||
INVENTREE_PLUGINS_ENABLED: 'True'
|
||||
},
|
||||
url: 'http://127.0.0.1:8000/api/',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
14
src/frontend/src/components/nav/Panel.tsx
Normal file
14
src/frontend/src/components/nav/Panel.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Type used to specify a single panel in a panel group
|
||||
*/
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
showHeadline?: boolean;
|
||||
};
|
@ -20,29 +20,34 @@ import {
|
||||
useParams
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { usePluginPanels } from '../../hooks/UsePluginPanels';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { PanelType } from './Panel';
|
||||
|
||||
/**
|
||||
* Type used to specify a single panel in a panel group
|
||||
* Set of properties which define a panel group:
|
||||
*
|
||||
* @param pageKey - Unique key for this panel group
|
||||
* @param panels - List of panels to display
|
||||
* @param model - The target model for this panel group (e.g. 'part' / 'salesorder')
|
||||
* @param id - The target ID for this panel group (set to *null* for groups which do not target a specific model instance)
|
||||
* @param instance - The target model instance for this panel group
|
||||
* @param selectedPanel - The currently selected panel
|
||||
* @param onPanelChange - Callback when the active panel changes
|
||||
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
|
||||
*/
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
showHeadline?: boolean;
|
||||
};
|
||||
|
||||
export type PanelProps = {
|
||||
pageKey: string;
|
||||
panels: PanelType[];
|
||||
instance?: any;
|
||||
model?: ModelType | string;
|
||||
id?: number | null;
|
||||
selectedPanel?: string;
|
||||
onPanelChange?: (panel: string) => void;
|
||||
collapsible?: boolean;
|
||||
@ -53,35 +58,39 @@ function BasePanelGroup({
|
||||
panels,
|
||||
onPanelChange,
|
||||
selectedPanel,
|
||||
instance,
|
||||
model,
|
||||
id,
|
||||
collapsible = true
|
||||
}: Readonly<PanelProps>): ReactNode {
|
||||
const localState = useLocalState();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { panel } = useParams();
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
// Hook to load plugins for this panel
|
||||
const pluginPanels = usePluginPanels({
|
||||
model: model,
|
||||
instance: instance,
|
||||
id: id
|
||||
});
|
||||
|
||||
const allPanels = useMemo(
|
||||
() => [...panels, ...pluginPanels],
|
||||
[panels, pluginPanels]
|
||||
);
|
||||
|
||||
const activePanels = useMemo(
|
||||
() => panels.filter((panel) => !panel.hidden && !panel.disabled),
|
||||
[panels]
|
||||
() => allPanels.filter((panel) => !panel.hidden && !panel.disabled),
|
||||
[allPanels]
|
||||
);
|
||||
|
||||
const setLastUsedPanel = useLocalState((state) =>
|
||||
state.setLastUsedPanel(pageKey)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
setLastUsedPanel(panel);
|
||||
}
|
||||
// panel is intentionally no dependency as this should only run on initial render
|
||||
}, [setLastUsedPanel]);
|
||||
|
||||
// Callback when the active panel changes
|
||||
const handlePanelChange = useCallback(
|
||||
(panel: string | null, event?: any) => {
|
||||
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||
panel = '';
|
||||
}
|
||||
|
||||
(panel: string, event?: any) => {
|
||||
if (event && (event?.ctrlKey || event?.shiftKey)) {
|
||||
const url = `${location.pathname}/../${panel}`;
|
||||
cancelEvent(event);
|
||||
@ -90,12 +99,14 @@ function BasePanelGroup({
|
||||
navigate(`../${panel}`);
|
||||
}
|
||||
|
||||
localState.setLastUsedPanel(pageKey)(panel);
|
||||
|
||||
// Optionally call external callback hook
|
||||
if (panel && onPanelChange) {
|
||||
onPanelChange(panel);
|
||||
}
|
||||
},
|
||||
[activePanels, setLastUsedPanel, navigate, location, onPanelChange]
|
||||
[activePanels, navigate, location, onPanelChange]
|
||||
);
|
||||
|
||||
// if the selected panel state changes update the current panel
|
||||
@ -105,32 +116,32 @@ function BasePanelGroup({
|
||||
}
|
||||
}, [selectedPanel, panel]);
|
||||
|
||||
// Update the active panel when panels changes and the active is no longer available
|
||||
useEffect(() => {
|
||||
// Determine the current panels selection (must be a valid panel)
|
||||
const currentPanel: string = useMemo(() => {
|
||||
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||
setLastUsedPanel('');
|
||||
return navigate('../');
|
||||
return activePanels[0]?.name ?? '';
|
||||
} else {
|
||||
return panel ?? '';
|
||||
}
|
||||
}, [activePanels, panel]);
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<Boundary label={`PanelGroup-${pageKey}`}>
|
||||
<Paper p="sm" radius="xs" shadow="xs">
|
||||
<Tabs value={panel} orientation="vertical" keepMounted={false}>
|
||||
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}>
|
||||
<Tabs.List justify="left">
|
||||
{panels.map(
|
||||
{allPanels.map(
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tooltip
|
||||
label={panel.label}
|
||||
label={`tooltip-${panel.name}`}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
position="right"
|
||||
>
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
key={`panel-label-${panel.name}`}
|
||||
value={panel.name}
|
||||
leftSection={panel.icon}
|
||||
hidden={panel.hidden}
|
||||
@ -162,11 +173,11 @@ function BasePanelGroup({
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Tabs.List>
|
||||
{panels.map(
|
||||
{allPanels.map(
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tabs.Panel
|
||||
key={panel.name}
|
||||
key={`panel-${panel.name}`}
|
||||
value={panel.name}
|
||||
aria-label={`nav-panel-${identifierString(
|
||||
`${pageKey}-${panel.name}`
|
||||
|
36
src/frontend/src/components/plugins/PluginContext.tsx
Normal file
36
src/frontend/src/components/plugins/PluginContext.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { MantineColorScheme, MantineTheme } from '@mantine/core';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { SettingsStateProps } from '../../states/SettingsState';
|
||||
import { UserStateProps } from '../../states/UserState';
|
||||
|
||||
/*
|
||||
* A set of properties which are passed to a plugin,
|
||||
* for rendering an element in the user interface.
|
||||
*
|
||||
* @param model - The model type for the plugin (e.g. 'part' / 'purchaseorder')
|
||||
* @param id - The ID (primary key) of the model instance for the plugin
|
||||
* @param instance - The model instance data (if available)
|
||||
* @param api - The Axios API instance (see ../states/ApiState.tsx)
|
||||
* @param user - The current user instance (see ../states/UserState.tsx)
|
||||
* @param userSettings - The current user settings (see ../states/SettingsState.tsx)
|
||||
* @param globalSettings - The global settings (see ../states/SettingsState.tsx)
|
||||
* @param navigate - The navigation function (see react-router-dom)
|
||||
* @param theme - The current Mantine theme
|
||||
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
|
||||
*/
|
||||
export type PluginContext = {
|
||||
model?: ModelType | string;
|
||||
id?: string | number | null;
|
||||
instance?: any;
|
||||
api: AxiosInstance;
|
||||
user: UserStateProps;
|
||||
userSettings: SettingsStateProps;
|
||||
globalSettings: SettingsStateProps;
|
||||
host: string;
|
||||
navigate: NavigateFunction;
|
||||
theme: MantineTheme;
|
||||
colorScheme: MantineColorScheme;
|
||||
};
|
122
src/frontend/src/components/plugins/PluginPanel.tsx
Normal file
122
src/frontend/src/components/plugins/PluginPanel.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { PluginContext } from './PluginContext';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
|
||||
// Definition of the plugin panel properties, provided by the server API
|
||||
export type PluginPanelProps = {
|
||||
plugin: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export async function isPluginPanelHidden({
|
||||
pluginProps,
|
||||
pluginContext
|
||||
}: {
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginContext: PluginContext;
|
||||
}): Promise<boolean> {
|
||||
if (!pluginProps.source) {
|
||||
// No custom source supplied - panel is not hidden
|
||||
return false;
|
||||
}
|
||||
|
||||
const func = await findExternalPluginFunction(
|
||||
pluginProps.source,
|
||||
'isPanelHidden'
|
||||
);
|
||||
|
||||
if (!func) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return func(pluginContext);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error occurred while checking if plugin panel is hidden:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom panel which can be used to display plugin content.
|
||||
*
|
||||
* - Content is loaded dynamically (via the API) when a page is first loaded
|
||||
* - Content can be provided from an external javascript module, or with raw HTML
|
||||
*
|
||||
* If content is provided from an external source, it is expected to define a function `render_panel` which will render the content.
|
||||
* const render_panel = (element: HTMLElement, params: any) => {...}
|
||||
*
|
||||
* Where:
|
||||
* - `element` is the HTML element to render the content into
|
||||
* - `params` is the set of run-time parameters to pass to the content rendering function
|
||||
*/
|
||||
export default function PluginPanelContent({
|
||||
pluginProps,
|
||||
pluginContext
|
||||
}: {
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginContext: PluginContext;
|
||||
}): ReactNode {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const reloadPluginContent = async () => {
|
||||
// If a "source" URL is provided, load the content from that URL
|
||||
if (pluginProps.source) {
|
||||
findExternalPluginFunction(pluginProps.source, 'renderPanel').then(
|
||||
(func) => {
|
||||
if (func) {
|
||||
try {
|
||||
func(ref.current, pluginContext);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
setError(t`Error occurred while rendering plugin content`);
|
||||
}
|
||||
} else {
|
||||
setError(t`Plugin did not provide panel rendering function`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (pluginProps.content) {
|
||||
// If content is provided directly, render it into the panel
|
||||
if (ref.current) {
|
||||
ref.current?.setHTMLUnsafe(pluginProps.content.toString());
|
||||
setError('');
|
||||
}
|
||||
} else {
|
||||
// If no content is provided, display a placeholder
|
||||
setError(t`No content provided for this plugin`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadPluginContent();
|
||||
}, [pluginProps, pluginContext]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error Loading Plugin`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div ref={ref as any}></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
47
src/frontend/src/components/plugins/PluginSource.tsx
Normal file
47
src/frontend/src/components/plugins/PluginSource.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
/*
|
||||
* Load an external plugin source from a URL.
|
||||
*/
|
||||
export async function loadExternalPluginSource(source: string) {
|
||||
const host = useLocalState.getState().host;
|
||||
|
||||
source = source.trim();
|
||||
|
||||
// If no source is provided, clear the plugin content
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the source is a relative URL, prefix it with the host URL
|
||||
if (source.startsWith('/')) {
|
||||
source = `${host}${source}`;
|
||||
}
|
||||
|
||||
const module = await import(/* @vite-ignore */ source)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load plugin source:', error);
|
||||
return null;
|
||||
})
|
||||
.then((module) => {
|
||||
return module;
|
||||
});
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
/*
|
||||
* Find a named function in an external plugin source.
|
||||
*/
|
||||
export async function findExternalPluginFunction(
|
||||
source: string,
|
||||
functionName: string
|
||||
) {
|
||||
const module = await loadExternalPluginSource(source);
|
||||
|
||||
if (module && module[functionName]) {
|
||||
return module[functionName];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -182,6 +182,9 @@ export enum ApiEndpoints {
|
||||
plugin_activate = 'plugins/:key/activate/',
|
||||
plugin_uninstall = 'plugins/:key/uninstall/',
|
||||
|
||||
// User interface plugin endpoints
|
||||
plugin_panel_list = 'plugins/ui/panels/',
|
||||
|
||||
// Machine API endpoints
|
||||
machine_types_list = 'machine/types/',
|
||||
machine_driver_list = 'machine/drivers/',
|
||||
|
@ -57,6 +57,7 @@ import {
|
||||
IconPaperclip,
|
||||
IconPhone,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconPoint,
|
||||
IconPrinter,
|
||||
IconProgressCheck,
|
||||
@ -217,7 +218,8 @@ const icons = {
|
||||
destination: IconFlag,
|
||||
repeat_destination: IconFlagShare,
|
||||
unlink: IconUnlink,
|
||||
success: IconCircleCheck
|
||||
success: IconCircleCheck,
|
||||
plugin: IconPlug
|
||||
};
|
||||
|
||||
export type InvenTreeIconType = keyof typeof icons;
|
||||
|
149
src/frontend/src/hooks/UsePluginPanels.tsx
Normal file
149
src/frontend/src/hooks/UsePluginPanels.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../App';
|
||||
import { PanelType } from '../components/nav/Panel';
|
||||
import { PluginContext } from '../components/plugins/PluginContext';
|
||||
import {
|
||||
PluginPanelProps,
|
||||
isPluginPanelHidden
|
||||
} from '../components/plugins/PluginPanel';
|
||||
import PluginPanelContent from '../components/plugins/PluginPanel';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { identifierString } from '../functions/conversion';
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../states/SettingsState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
|
||||
export function usePluginPanels({
|
||||
instance,
|
||||
model,
|
||||
id
|
||||
}: {
|
||||
instance?: any;
|
||||
model?: ModelType | string;
|
||||
id?: string | number | null;
|
||||
}): PanelType[] {
|
||||
const host = useLocalState.getState().host;
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const pluginPanelsEnabled: boolean = useMemo(
|
||||
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
// API query to fetch initial information on available plugin panels
|
||||
const { data: pluginData } = useQuery({
|
||||
enabled: pluginPanelsEnabled && !!model && id !== undefined,
|
||||
queryKey: ['custom-plugin-panels', model, id],
|
||||
queryFn: async () => {
|
||||
if (!pluginPanelsEnabled || !model) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.plugin_panel_list), {
|
||||
params: {
|
||||
target_model: model,
|
||||
target_id: id
|
||||
}
|
||||
})
|
||||
.then((response: any) => response.data)
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to fetch plugin panels:', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the context data which is delivered to the plugins
|
||||
const contextData: PluginContext = useMemo(() => {
|
||||
return {
|
||||
model: model,
|
||||
id: id,
|
||||
instance: instance,
|
||||
user: user,
|
||||
host: host,
|
||||
api: api,
|
||||
navigate: navigate,
|
||||
globalSettings: globalSettings,
|
||||
userSettings: userSettings,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme
|
||||
};
|
||||
}, [
|
||||
model,
|
||||
id,
|
||||
instance,
|
||||
user,
|
||||
host,
|
||||
api,
|
||||
navigate,
|
||||
globalSettings,
|
||||
userSettings,
|
||||
theme,
|
||||
colorScheme
|
||||
]);
|
||||
|
||||
// Track which panels are hidden: { panelName: true/false }
|
||||
// We need to memoize this as the plugins can determine this dynamically
|
||||
const [panelState, setPanelState] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Clear the visibility cache when the plugin data changes
|
||||
// This will force the plugin panels to re-calculate their visibility
|
||||
useEffect(() => {
|
||||
pluginData?.forEach((props: PluginPanelProps) => {
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
|
||||
// Check if the panel is hidden (defaults to true until we know otherwise)
|
||||
isPluginPanelHidden({
|
||||
pluginProps: props,
|
||||
pluginContext: contextData
|
||||
}).then((result) => {
|
||||
setPanelState((prev) => ({ ...prev, [identifier]: result }));
|
||||
});
|
||||
});
|
||||
}, [pluginData, contextData]);
|
||||
|
||||
const pluginPanels: PanelType[] = useMemo(() => {
|
||||
return (
|
||||
pluginData?.map((props: PluginPanelProps) => {
|
||||
const iconName: string = props.icon || 'plugin';
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
const isHidden: boolean = panelState[identifier] ?? true;
|
||||
|
||||
return {
|
||||
name: identifier,
|
||||
label: props.label,
|
||||
icon: <InvenTreeIcon icon={iconName as InvenTreeIconType} />,
|
||||
content: (
|
||||
<PluginPanelContent
|
||||
pluginProps={props}
|
||||
pluginContext={contextData}
|
||||
/>
|
||||
),
|
||||
hidden: isHidden
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [panelState, pluginData, contextData]);
|
||||
|
||||
return pluginPanels;
|
||||
}
|
@ -11,6 +11,7 @@ import ReactDOM from 'react-dom/client';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
import { api } from './App';
|
||||
import { HostList } from './states/states';
|
||||
import MainView from './views/MainView';
|
||||
|
||||
@ -25,6 +26,8 @@ declare global {
|
||||
sentry_dsn?: string;
|
||||
environment?: string;
|
||||
};
|
||||
InvenTreeAPI: typeof api;
|
||||
React: typeof React;
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,3 +102,6 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
if (window.location.pathname === '/') {
|
||||
window.location.replace(`/${base_url}`);
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.InvenTreeAPI = api;
|
||||
|
@ -28,7 +28,8 @@ import { lazy, useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPill } from '../../../../components/items/Placeholder';
|
||||
import { PanelGroup, PanelType } from '../../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Accordion, Alert, Stack } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { userInfo } from 'os';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
import { useServerApiState } from '../../../../states/ApiState';
|
||||
import { useUserState } from '../../../../states/UserState';
|
||||
|
||||
const PluginListTable = Loadable(
|
||||
lazy(() => import('../../../../tables/plugin/PluginListTable'))
|
||||
@ -21,6 +23,8 @@ export default function PluginManagementPanel() {
|
||||
(state) => state.server.plugins_enabled
|
||||
);
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{!pluginsEnabled && (
|
||||
@ -45,15 +49,6 @@ export default function PluginManagementPanel() {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="pluginerror">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PluginErrorTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="pluginsettings">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Settings`}</StylishText>
|
||||
@ -63,6 +58,7 @@ export default function PluginManagementPanel() {
|
||||
keys={[
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_EVENTS',
|
||||
'ENABLE_PLUGINS_INTERFACE',
|
||||
'ENABLE_PLUGINS_URL',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_APP',
|
||||
@ -72,6 +68,16 @@ export default function PluginManagementPanel() {
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{user.isSuperuser() && (
|
||||
<Accordion.Item value="pluginerror">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PluginErrorTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -21,7 +21,8 @@ import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPanel } from '../../../components/items/Placeholder';
|
||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { useServerApiState } from '../../../states/ApiState';
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
@ -33,7 +33,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -536,7 +537,13 @@ export default function BuildDetail() {
|
||||
]}
|
||||
actions={buildActions}
|
||||
/>
|
||||
<PanelGroup pageKey="build" panels={buildPanels} />
|
||||
<PanelGroup
|
||||
pageKey="build"
|
||||
panels={buildPanels}
|
||||
instance={build}
|
||||
model={ModelType.build}
|
||||
id={build.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -31,7 +31,8 @@ import {
|
||||
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -344,7 +345,13 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
editAction={editCompany.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.company)}
|
||||
/>
|
||||
<PanelGroup pageKey="company" panels={companyPanels} />
|
||||
<PanelGroup
|
||||
pageKey="company"
|
||||
panels={companyPanels}
|
||||
instance={company}
|
||||
model={ModelType.company}
|
||||
id={company.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -23,7 +23,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -284,7 +285,13 @@ export default function ManufacturerPartDetail() {
|
||||
editAction={editManufacturerPart.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.manufacturerpart)}
|
||||
/>
|
||||
<PanelGroup pageKey="manufacturerpart" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="manufacturerpart"
|
||||
panels={panels}
|
||||
instance={manufacturerPart}
|
||||
model={ModelType.manufacturerpart}
|
||||
id={manufacturerPart.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -25,7 +25,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -361,7 +362,13 @@ export default function SupplierPartDetail() {
|
||||
editAction={editSupplierPart.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.supplierpart)}
|
||||
/>
|
||||
<PanelGroup pageKey="supplierpart" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="supplierpart"
|
||||
panels={panels}
|
||||
instance={supplierPart}
|
||||
model={ModelType.supplierpart}
|
||||
id={supplierPart.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -21,7 +21,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -229,7 +230,7 @@ export default function CategoryDetail() {
|
||||
];
|
||||
}, [id, user, category.pk]);
|
||||
|
||||
const categoryPanels: PanelType[] = useMemo(
|
||||
const panels: PanelType[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'details',
|
||||
@ -311,7 +312,13 @@ export default function CategoryDetail() {
|
||||
editAction={editCategory.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.partcategory)}
|
||||
/>
|
||||
<PanelGroup pageKey="partcategory" panels={categoryPanels} />
|
||||
<PanelGroup
|
||||
pageKey="partcategory"
|
||||
panels={panels}
|
||||
model={ModelType.partcategory}
|
||||
instance={category}
|
||||
id={category.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -58,7 +58,8 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -1122,7 +1123,13 @@ export default function PartDetail() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
<PanelGroup
|
||||
pageKey="part"
|
||||
panels={partPanels}
|
||||
instance={part}
|
||||
model={ModelType.part}
|
||||
id={part.pk}
|
||||
/>
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
|
@ -28,7 +28,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -469,7 +470,13 @@ export default function PurchaseOrderDetail() {
|
||||
editAction={editPurchaseOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.purchaseorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="purchaseorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="purchaseorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.purchaseorder}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -58,7 +58,12 @@ export default function PurchasingIndex() {
|
||||
return (
|
||||
<Stack>
|
||||
<PageDetail title={t`Purchasing`} />
|
||||
<PanelGroup pageKey="purchasing-index" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="purchasing-index"
|
||||
panels={panels}
|
||||
model={'purchasing'}
|
||||
id={null}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -27,7 +27,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -458,7 +459,13 @@ export default function ReturnOrderDetail() {
|
||||
editAction={editReturnOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.returnorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="returnorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="returnorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.returnorder}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -51,7 +51,12 @@ export default function PurchasingIndex() {
|
||||
return (
|
||||
<Stack>
|
||||
<PageDetail title={t`Sales`} />
|
||||
<PanelGroup pageKey="sales-index" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="sales-index"
|
||||
panels={panels}
|
||||
model={'sales'}
|
||||
id={null}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -509,7 +510,13 @@ export default function SalesOrderDetail() {
|
||||
editAction={editSalesOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.salesorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="salesorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="salesorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.salesorder}
|
||||
id={order.pk}
|
||||
instance={order}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -20,7 +20,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -387,7 +388,13 @@ export default function Stock() {
|
||||
setTreeOpen(true);
|
||||
}}
|
||||
/>
|
||||
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
|
||||
<PanelGroup
|
||||
pageKey="stocklocation"
|
||||
panels={locationPanels}
|
||||
model={ModelType.stocklocation}
|
||||
id={location.pk}
|
||||
instance={location}
|
||||
/>
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
|
@ -33,7 +33,8 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -616,7 +617,13 @@ export default function StockDetail() {
|
||||
}}
|
||||
actions={stockActions}
|
||||
/>
|
||||
<PanelGroup pageKey="stockitem" panels={stockPanels} />
|
||||
<PanelGroup
|
||||
pageKey="stockitem"
|
||||
panels={stockPanels}
|
||||
model={ModelType.stockitem}
|
||||
id={stockitem.pk}
|
||||
instance={stockitem}
|
||||
/>
|
||||
{editStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
|
@ -8,7 +8,7 @@ import { clearCsrfCookie } from '../functions/auth';
|
||||
import { apiUrl } from './ApiState';
|
||||
import { UserProps } from './states';
|
||||
|
||||
interface UserStateProps {
|
||||
export interface UserStateProps {
|
||||
user: UserProps | undefined;
|
||||
token: string | undefined;
|
||||
username: () => string;
|
||||
|
@ -352,7 +352,10 @@ export default function PluginListTable() {
|
||||
// Determine available actions for a given plugin
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
// TODO: Plugin actions should be updated based on on the users's permissions
|
||||
// Only superuser can perform plugin actions
|
||||
if (!user.isSuperuser()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let actions: RowAction[] = [];
|
||||
|
||||
@ -505,33 +508,28 @@ export default function PluginListTable() {
|
||||
|
||||
// Custom table actions
|
||||
const tableActions = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
if (user.user?.is_superuser && pluginsEnabled) {
|
||||
actions.push(
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconRefresh />}
|
||||
tooltip={t`Reload Plugins`}
|
||||
onClick={reloadPlugins}
|
||||
/>
|
||||
);
|
||||
|
||||
actions.push(
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconPlaylistAdd />}
|
||||
tooltip={t`Install Plugin`}
|
||||
onClick={() => {
|
||||
setPluginPackage('');
|
||||
installPluginModal.open();
|
||||
}}
|
||||
disabled={plugins_install_disabled || false}
|
||||
/>
|
||||
);
|
||||
if (!user.isSuperuser() || !pluginsEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions;
|
||||
return [
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconRefresh />}
|
||||
tooltip={t`Reload Plugins`}
|
||||
onClick={reloadPlugins}
|
||||
/>,
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconPlaylistAdd />}
|
||||
tooltip={t`Install Plugin`}
|
||||
onClick={() => {
|
||||
setPluginPackage('');
|
||||
installPluginModal.open();
|
||||
}}
|
||||
disabled={plugins_install_disabled || false}
|
||||
/>
|
||||
];
|
||||
}, [user, pluginsEnabled]);
|
||||
|
||||
return (
|
||||
|
@ -69,7 +69,7 @@ test('PUI - Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||
.getByRole('table')
|
||||
.getByText('Wood Screw')
|
||||
.click();
|
||||
await page.waitForURL('**/part/98/pricing');
|
||||
await page.waitForURL('**/part/98/**');
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Pricing (Supplier)', async ({ page }) => {
|
||||
@ -121,7 +121,7 @@ test('PUI - Pages - Part - Pricing (Variant)', async ({ page }) => {
|
||||
let target = page.getByText('Green Chair').first();
|
||||
await target.waitFor();
|
||||
await target.click();
|
||||
await page.waitForURL('**/part/109/pricing');
|
||||
await page.waitForURL('**/part/109/**');
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Pricing (Internal)', async ({ page }) => {
|
||||
|
104
src/frontend/tests/pui_plugins.spec.ts
Normal file
104
src/frontend/tests/pui_plugins.spec.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import test, { Page, expect, request } from 'playwright/test';
|
||||
|
||||
import { baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
/*
|
||||
* Set the value of a global setting in the database
|
||||
*/
|
||||
const setSettingState = async ({
|
||||
request,
|
||||
setting,
|
||||
value
|
||||
}: {
|
||||
request: any;
|
||||
setting: string;
|
||||
value: any;
|
||||
}) => {
|
||||
const url = `http://localhost:8000/api/settings/global/${setting}/`;
|
||||
|
||||
const response = await request.patch(url, {
|
||||
data: {
|
||||
value: value
|
||||
},
|
||||
headers: {
|
||||
// Basic username: password authorization
|
||||
Authorization: `Basic ${btoa('admin:inventree')}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(await response.status()).toBe(200);
|
||||
};
|
||||
|
||||
const setPluginState = async ({
|
||||
request,
|
||||
plugin,
|
||||
state
|
||||
}: {
|
||||
request: any;
|
||||
plugin: string;
|
||||
state: boolean;
|
||||
}) => {
|
||||
const url = `http://localhost:8000/api/plugins/${plugin}/activate/`;
|
||||
|
||||
const response = await request.patch(url, {
|
||||
data: {
|
||||
active: state
|
||||
},
|
||||
headers: {
|
||||
// Basic username: password authorization
|
||||
Authorization: `Basic ${btoa('admin:inventree')}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(await response.status()).toBe(200);
|
||||
};
|
||||
|
||||
test('Plugins - Panels', async ({ page, request }) => {
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
|
||||
// Ensure that UI plugins are enabled
|
||||
await setSettingState({
|
||||
request,
|
||||
setting: 'ENABLE_PLUGINS_INTERFACE',
|
||||
value: true
|
||||
});
|
||||
|
||||
// Ensure that the SampleUI plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sampleui',
|
||||
state: true
|
||||
});
|
||||
|
||||
// Navigate to the "part" page
|
||||
await page.goto(`${baseUrl}/part/69/`);
|
||||
|
||||
// Ensure basic part tab is available
|
||||
await page.getByRole('tab', { name: 'Part Details' }).waitFor();
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await page.getByRole('tab', { name: 'Sample Panel' }).click();
|
||||
await page
|
||||
.getByText('This is a sample panel which appears on every page')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Broken Panel' }).click();
|
||||
await page.getByText('Error Loading Plugin').waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Dynamic Part Panel' }).click();
|
||||
await page
|
||||
.getByText('This panel has been dynamically rendered by the plugin system')
|
||||
.waitFor();
|
||||
await page.getByText('Instance ID: 69');
|
||||
|
||||
await page.getByRole('tab', { name: 'Part Panel', exact: true }).click();
|
||||
await page.getByText('This content has been rendered by a custom plugin');
|
||||
|
||||
// Disable the plugin, and ensure it is no longer visible
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sampleui',
|
||||
state: false
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user