2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-06 00:44:25 +00:00

Fix for RenderInstance (#12058)

* Fix for RenderInstance

- Do not call useNavigate within RenderInstance
- This breaks any plugins which try to use RenderInstance
- Pass navigate func through to <RenderInstance />

* Add playwright test for hover in forms

* Add translated plugin test

* Improve playwright tests

* Further unit test fixes

* Update docstring
This commit is contained in:
Oliver
2026-06-01 21:44:02 +10:00
committed by GitHub
parent f912ba501d
commit 7f86384a03
11 changed files with 155 additions and 19 deletions
+5
View File
@@ -726,6 +726,11 @@ jobs:
- name: Install Playwright OS dependencies - name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true' if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Run Playwright tests - name: Run Playwright tests
id: tests id: tests
run: | run: |
@@ -121,3 +121,7 @@ class TransitionTests(InvenTreeTestCase):
self.assertIn( self.assertIn(
"ValueError('This is a broken transition plugin!')", str(cm.output[0]) "ValueError('This is a broken transition plugin!')", str(cm.output[0])
) )
# Ensure the plugin is now disabled
registry.set_plugin_state('sample-transition', False)
registry.set_plugin_state('sample-broken-transition', False)
@@ -60,7 +60,7 @@ class TransitionMethod:
**kwargs: Additional keyword arguments for custom logic. **kwargs: Additional keyword arguments for custom logic.
Returns: Returns:
result: bool - True if the transition method was successful, False otherwise. result: bool - True if the transition method was successful (and no further transitions are attempted), False otherwise.
Raises: Raises:
ValidationError: Alert the user that the transition failed ValidationError: Alert the user that the transition failed
+3
View File
@@ -2,6 +2,7 @@ import type { DefaultMantineColor, MantineStyleProp } from '@mantine/core';
import type { UseFormReturnType } from '@mantine/form'; import type { UseFormReturnType } from '@mantine/form';
import type { JSX, ReactNode } from 'react'; import type { JSX, ReactNode } from 'react';
import type { FieldValues, UseFormReturn } from 'react-hook-form'; import type { FieldValues, UseFormReturn } from 'react-hook-form';
import type { NavigateFunction } from 'react-router-dom';
import type { ApiEndpoints } from '../enums/ApiEndpoints'; import type { ApiEndpoints } from '../enums/ApiEndpoints';
import type { ModelType } from '../enums/ModelType'; import type { ModelType } from '../enums/ModelType';
import type { PathParams, UiSizeType } from './Core'; import type { PathParams, UiSizeType } from './Core';
@@ -160,6 +161,7 @@ export type ApiFormFieldSet = Record<string, ApiFormFieldType>;
* @param modelType : Define a model type for this form * @param modelType : Define a model type for this form
* @param follow : Boolean, follow the result of the form (if possible) * @param follow : Boolean, follow the result of the form (if possible)
* @param table : Table to update on success (if provided) * @param table : Table to update on success (if provided)
* @param navigate : Optional navigate function to use for following results (if follow is true)
*/ */
export interface ApiFormProps { export interface ApiFormProps {
url: ApiEndpoints | string; url: ApiEndpoints | string;
@@ -189,6 +191,7 @@ export interface ApiFormProps {
follow?: boolean; follow?: boolean;
actions?: ApiFormAction[]; actions?: ApiFormAction[];
timeout?: number; timeout?: number;
navigate?: NavigateFunction;
keepOpenOption?: boolean; keepOpenOption?: boolean;
onKeepOpenChange?: (keepOpen: boolean) => void; onKeepOpenChange?: (keepOpen: boolean) => void;
} }
+10 -8
View File
@@ -20,7 +20,7 @@ import {
type SubmitHandler, type SubmitHandler,
useForm useForm
} from 'react-hook-form'; } from 'react-hook-form';
import { type NavigateFunction, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Boundary } from '@lib/components/Boundary'; import { Boundary } from '@lib/components/Boundary';
import { isTrue } from '@lib/functions/Conversion'; import { isTrue } from '@lib/functions/Conversion';
@@ -176,14 +176,15 @@ export function ApiForm({
props.onKeepOpenChange?.(v); props.onKeepOpenChange?.(v);
}; };
// Accessor for the navigation function (which is used to redirect the user) let navigate = props.navigate || null;
let navigate: NavigateFunction | null = null;
try { if (!navigate) {
navigate = useNavigate(); try {
} catch (_error) { navigate = useNavigate();
// Note: If we launch a form within a plugin context, useNavigate() may not be available } catch (_error) {
navigate = null; // Note: If we launch a form within a plugin context, useNavigate() may not be available
navigate = null;
}
} }
const [fields, setFields] = useState<ApiFormFieldSet>( const [fields, setFields] = useState<ApiFormFieldSet>(
@@ -658,6 +659,7 @@ export function ApiForm({
definition={field} definition={field}
control={form.control} control={form.control}
url={url} url={url}
navigate={navigate}
setFields={setFields} setFields={setFields}
onKeyDown={(value) => { onKeyDown={(value) => {
if ( if (
@@ -6,6 +6,7 @@ import { type Control, type FieldValues, useController } from 'react-hook-form';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { IconFileUpload } from '@tabler/icons-react'; import { IconFileUpload } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import DateTimeField from '../DateTimeField'; import DateTimeField from '../DateTimeField';
import { BooleanField } from './BooleanField'; import { BooleanField } from './BooleanField';
import { ChoiceField } from './ChoiceField'; import { ChoiceField } from './ChoiceField';
@@ -26,6 +27,7 @@ export function ApiFormField({
definition, definition,
control, control,
hideLabels, hideLabels,
navigate,
url, url,
setFields, setFields,
onKeyDown onKeyDown
@@ -34,6 +36,7 @@ export function ApiFormField({
definition: ApiFormFieldType; definition: ApiFormFieldType;
control: Control<FieldValues, any>; control: Control<FieldValues, any>;
hideLabels?: boolean; hideLabels?: boolean;
navigate?: NavigateFunction | null;
url?: string; url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>; setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
onKeyDown?: (value: any) => void; onKeyDown?: (value: any) => void;
@@ -119,9 +122,10 @@ export function ApiFormField({
case 'related field': case 'related field':
return ( return (
<RelatedModelField <RelatedModelField
controller={controller}
definition={fieldDefinition} definition={fieldDefinition}
controller={controller}
fieldName={fieldName} fieldName={fieldName}
navigate={navigate}
/> />
); );
case 'email': case 'email':
@@ -32,6 +32,7 @@ import {
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldType } from '@lib/types/Forms';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { useApi } from '../../../contexts/ApiContext'; import { useApi } from '../../../contexts/ApiContext';
import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { useCreateApiFormModal } from '../../../hooks/UseForm';
import { import {
@@ -50,11 +51,13 @@ export function RelatedModelField({
controller, controller,
fieldName, fieldName,
definition, definition,
navigate,
limit = 10 limit = 10
}: Readonly<{ }: Readonly<{
controller: UseControllerReturn<FieldValues, any>; controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType; definition: ApiFormFieldType;
fieldName: string; fieldName: string;
navigate?: NavigateFunction | null;
limit?: number; limit?: number;
}>) { }>) {
const api = useApi(); const api = useApi();
@@ -28,9 +28,13 @@ import type {
} from '@lib/types/Rendering'; } from '@lib/types/Rendering';
export type { InstanceRenderInterface } from '@lib/types/Rendering'; export type { InstanceRenderInterface } from '@lib/types/Rendering';
import { getBaseUrl, navigateToLink, shortenString } from '@lib/index'; import {
getBaseUrl,
getDetailUrl,
navigateToLink,
shortenString
} from '@lib/index';
import { IconLink } from '@tabler/icons-react'; import { IconLink } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { usePluginState } from '../../states/PluginState'; import { usePluginState } from '../../states/PluginState';
import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserSettingsState } from '../../states/SettingsStates';
@@ -132,7 +136,6 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
props.custom_model ?? props.model ?? '' props.custom_model ?? props.model ?? ''
); );
const navigate = useNavigate();
const userSettings = useUserSettingsState(); const userSettings = useUserSettingsState();
// Extract model information from the defined model type // Extract model information from the defined model type
@@ -170,12 +173,12 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
}, [modelInfo, props.instance]); }, [modelInfo, props.instance]);
const detailUrl = useMemo(() => { const detailUrl = useMemo(() => {
if (!modelInfo || !modelId || !modelInfo.url_detail) { if (!props.model) {
return undefined; return undefined;
} }
return modelInfo.url_detail.replace(':pk', modelId.toString()); return getDetailUrl(props.model, modelId, true);
}, [modelInfo, modelId]); }, [props.model]);
return ( return (
<HoverCard <HoverCard
@@ -207,7 +210,11 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
<Anchor <Anchor
href={detailUrl} href={detailUrl}
target='_blank' target='_blank'
onClick={(event) => navigateToLink(detailUrl, navigate, event)} onClick={(event) => {
if (props.navigate) {
navigateToLink(detailUrl, props.navigate, event);
}
}}
> >
<Group gap='xs' wrap='nowrap'> <Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'> <ActionIcon variant='transparent' size='xs'>
+18 -2
View File
@@ -168,9 +168,14 @@ test('Stock - Filters', async ({ browser }) => {
// Filter by custom status code // Filter by custom status code
await clearTableFilters(page); await clearTableFilters(page);
await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection'); await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection');
await page.getByText('1 - 8 / 8').waitFor();
await page.getByRole('cell', { name: '1551AGY' }).first().waitFor(); await page.getByRole('cell', { name: '1551AGY' }).first().waitFor();
await page.getByPlaceholder('Search').clear();
await page.getByPlaceholder('Search').fill('blue');
await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor(); await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor();
await page.getByPlaceholder('Search').clear();
await page.getByPlaceholder('Search').fill('002.01');
await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor(); await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor();
await clearTableFilters(page); await clearTableFilters(page);
@@ -324,10 +329,20 @@ test('Stock - Stock Actions', async ({ browser }) => {
await page.getByRole('option', { name: status }).click(); await page.getByRole('option', { name: status }).click();
}; };
// Duplicate the stock item first - prevent impacting other tests
await page
.getByRole('button', { name: 'action-menu-stock-item-actions' })
.click();
await page
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForLoadState('networkidle');
// Check for required values // Check for required values
await page.getByText('Status', { exact: true }).waitFor(); await page.getByText('Status', { exact: true }).waitFor();
await page.getByText('Custom Status', { exact: true }).waitFor(); await page.getByText('Custom Status', { exact: true }).waitFor();
await page.getByText('Attention needed').waitFor(); await page.getByText('Attention needed').first().waitFor();
await page await page
.getByLabel('Stock Details') .getByLabel('Stock Details')
.getByText('Incoming goods inspection') .getByText('Incoming goods inspection')
@@ -705,6 +720,7 @@ test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).click(); await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Order issued').waitFor(); await page.getByText('Order issued').waitFor();
await page.getByText('Issued', { exact: true }).first().waitFor();
await loadTab(page, 'Line Items'); await loadTab(page, 'Line Items');
+35
View File
@@ -1,9 +1,44 @@
/** Unit tests for form validation, rendering, etc */ /** Unit tests for form validation, rendering, etc */
import { expect, test } from 'playwright/test'; import { expect, test } from 'playwright/test';
import { createApi } from './api';
import { stevenuser } from './defaults'; import { stevenuser } from './defaults';
import { navigate } from './helpers'; import { navigate } from './helpers';
import { doCachedLogin } from './login'; import { doCachedLogin } from './login';
// Test hover form action in related fields
test('Forms - Hover', async ({ browser }) => {
const page = await doCachedLogin(browser, {
user: stevenuser,
url: 'purchasing/index/purchaseorders'
});
// Patch user settings to ensure we can see "extra model info" on hover
const api = await createApi({
username: stevenuser.username,
password: stevenuser.testcred
});
const response = await api.patch('settings/user/SHOW_EXTRA_MODEL_INFO/', {
data: {
value: 'true'
}
});
expect(response.status()).toBe(200);
await page
.getByRole('button', { name: 'action-button-add-purchase-' })
.click();
await page.getByLabel('related-field-supplier').fill('mou');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(250);
await page.getByRole('option', { name: 'Mouser Electronics' }).hover();
// Check for hover info
await page.getByText('Company[ID: 2]').waitFor();
await page.getByRole('link', { name: 'View details' }).waitFor();
});
test('Forms - Stock Item Validation', async ({ browser }) => { test('Forms - Stock Item Validation', async ({ browser }) => {
const page = await doCachedLogin(browser, { const page = await doCachedLogin(browser, {
user: stevenuser, user: stevenuser,
+57
View File
@@ -253,3 +253,60 @@ test('Plugins - Locate Item', async ({ browser }) => {
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item location requested').waitFor(); await page.getByText('Item location requested').waitFor();
}); });
/**
* Perform a full run through of validating a UI plugin:
*
* - Activate the plugin
* - Check that a custom panel is added
* - Check that expected translated text is added
* - Check that expected UI elements can be operated
*
* Note: This tests assumes that:
*
* - The inventree-plugin-creator tool has been installed
* - The default plugin has been created, build and installed
*/
test('Plugins - Creator', async ({ browser }) => {
const page = await doCachedLogin(browser, {
user: adminuser
});
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
plugin: 'my-custom-plugin',
state: true
});
// Allow time for installation of plugin static files, etc
await page.waitForTimeout(2500);
await navigate(page, 'part/106/details/');
await loadTab(page, 'My Custom Plugin');
// Check for correctly translated code
await page.getByText('Translated text, provided by custom code!').waitFor();
// Check for incrementing counter value
for (let i = 0; i < 5; i++) {
await page.getByText(`Counter: ${i}`).waitFor();
await page.getByRole('button', { name: 'Increment Counter' }).click();
}
// Edit part form
await page.getByRole('button', { name: 'Edit Part' }).click();
await page
.getByText('This is a custom form launched from within a plugin!')
.waitFor();
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill('New part name');
await page.getByRole('button', { name: 'Cancel' }).click();
// View a custom table
await page.getByRole('button', { name: 'Custom Table Example' }).click();
await page.getByRole('textbox', { name: 'table-search-input' }).fill('red');
await page.waitForLoadState('networkidle');
await page.getByRole('cell', { name: 'Red Square Table' }).first().waitFor();
});
// Ensure that the sample full run plugin is enabled