2
0
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:
Oliver
2024-09-16 10:36:27 +10:00
committed by GitHub
parent df8269df2a
commit 12d2865b59
56 changed files with 1446 additions and 163 deletions

View File

@ -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,

View 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;
};

View File

@ -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}`

View 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;
};

View 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>
);
}

View 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;
}

View File

@ -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/',

View File

@ -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;

View 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;
}

View File

@ -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;

View File

@ -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';

View File

@ -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>
);

View File

@ -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';

View File

@ -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';

View File

@ -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>
</>

View File

@ -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>
</>

View File

@ -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>
</>

View File

@ -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>
</>

View File

@ -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>
</>

View File

@ -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>

View File

@ -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>
</>

View File

@ -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>
);
}

View File

@ -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>
</>

View File

@ -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>
);
}

View File

@ -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>
</>

View File

@ -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>

View File

@ -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}

View File

@ -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;

View File

@ -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 (

View File

@ -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 }) => {

View 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
});
});