diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index dd6fb87de1..78d979af3b 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -726,6 +726,11 @@ jobs: - name: Install Playwright OS dependencies if: steps.playwright-cache.outputs.cache-hit == 'true' 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 id: tests run: | diff --git a/src/backend/InvenTree/generic/states/test_transition.py b/src/backend/InvenTree/generic/states/test_transition.py index b8cb3c371d..37561ceb56 100644 --- a/src/backend/InvenTree/generic/states/test_transition.py +++ b/src/backend/InvenTree/generic/states/test_transition.py @@ -121,3 +121,7 @@ class TransitionTests(InvenTreeTestCase): self.assertIn( "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) diff --git a/src/backend/InvenTree/generic/states/transition.py b/src/backend/InvenTree/generic/states/transition.py index 80ae2f7f3c..785ac16600 100644 --- a/src/backend/InvenTree/generic/states/transition.py +++ b/src/backend/InvenTree/generic/states/transition.py @@ -60,7 +60,7 @@ class TransitionMethod: **kwargs: Additional keyword arguments for custom logic. 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: ValidationError: Alert the user that the transition failed diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 83e78a8e1a..b918205b97 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -2,6 +2,7 @@ import type { DefaultMantineColor, MantineStyleProp } from '@mantine/core'; import type { UseFormReturnType } from '@mantine/form'; import type { JSX, ReactNode } from 'react'; import type { FieldValues, UseFormReturn } from 'react-hook-form'; +import type { NavigateFunction } from 'react-router-dom'; import type { ApiEndpoints } from '../enums/ApiEndpoints'; import type { ModelType } from '../enums/ModelType'; import type { PathParams, UiSizeType } from './Core'; @@ -160,6 +161,7 @@ export type ApiFormFieldSet = Record; * @param modelType : Define a model type for this form * @param follow : Boolean, follow the result of the form (if possible) * @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 { url: ApiEndpoints | string; @@ -189,6 +191,7 @@ export interface ApiFormProps { follow?: boolean; actions?: ApiFormAction[]; timeout?: number; + navigate?: NavigateFunction; keepOpenOption?: boolean; onKeepOpenChange?: (keepOpen: boolean) => void; } diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 7531e9b927..df6c56e5ad 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -20,7 +20,7 @@ import { type SubmitHandler, useForm } 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 { isTrue } from '@lib/functions/Conversion'; @@ -176,14 +176,15 @@ export function ApiForm({ props.onKeepOpenChange?.(v); }; - // Accessor for the navigation function (which is used to redirect the user) - let navigate: NavigateFunction | null = null; + let navigate = props.navigate || null; - try { - navigate = useNavigate(); - } catch (_error) { - // Note: If we launch a form within a plugin context, useNavigate() may not be available - navigate = null; + if (!navigate) { + try { + navigate = useNavigate(); + } catch (_error) { + // Note: If we launch a form within a plugin context, useNavigate() may not be available + navigate = null; + } } const [fields, setFields] = useState( @@ -658,6 +659,7 @@ export function ApiForm({ definition={field} control={form.control} url={url} + navigate={navigate} setFields={setFields} onKeyDown={(value) => { if ( diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index cd53a45f1d..32acc3bdad 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -6,6 +6,7 @@ import { type Control, type FieldValues, useController } from 'react-hook-form'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { IconFileUpload } from '@tabler/icons-react'; +import type { NavigateFunction } from 'react-router-dom'; import DateTimeField from '../DateTimeField'; import { BooleanField } from './BooleanField'; import { ChoiceField } from './ChoiceField'; @@ -26,6 +27,7 @@ export function ApiFormField({ definition, control, hideLabels, + navigate, url, setFields, onKeyDown @@ -34,6 +36,7 @@ export function ApiFormField({ definition: ApiFormFieldType; control: Control; hideLabels?: boolean; + navigate?: NavigateFunction | null; url?: string; setFields?: React.Dispatch>; onKeyDown?: (value: any) => void; @@ -119,9 +122,10 @@ export function ApiFormField({ case 'related field': return ( ); case 'email': diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 70e427549e..bb88471c0c 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -32,6 +32,7 @@ import { import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldType } from '@lib/types/Forms'; import { IconPlus } from '@tabler/icons-react'; +import type { NavigateFunction } from 'react-router-dom'; import { useApi } from '../../../contexts/ApiContext'; import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { @@ -50,11 +51,13 @@ export function RelatedModelField({ controller, fieldName, definition, + navigate, limit = 10 }: Readonly<{ controller: UseControllerReturn; definition: ApiFormFieldType; fieldName: string; + navigate?: NavigateFunction | null; limit?: number; }>) { const api = useApi(); diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 150c45034e..258f800c58 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -28,9 +28,13 @@ import type { } 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 { useNavigate } from 'react-router-dom'; import { useApi } from '../../contexts/ApiContext'; import { usePluginState } from '../../states/PluginState'; import { useUserSettingsState } from '../../states/SettingsStates'; @@ -132,7 +136,6 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode { props.custom_model ?? props.model ?? '' ); - const navigate = useNavigate(); const userSettings = useUserSettingsState(); // Extract model information from the defined model type @@ -170,12 +173,12 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode { }, [modelInfo, props.instance]); const detailUrl = useMemo(() => { - if (!modelInfo || !modelId || !modelInfo.url_detail) { + if (!props.model) { return undefined; } - return modelInfo.url_detail.replace(':pk', modelId.toString()); - }, [modelInfo, modelId]); + return getDetailUrl(props.model, modelId, true); + }, [props.model]); return ( navigateToLink(detailUrl, navigate, event)} + onClick={(event) => { + if (props.navigate) { + navigateToLink(detailUrl, props.navigate, event); + } + }} > diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 9f9c345eeb..c9d5699d1c 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -168,9 +168,14 @@ test('Stock - Filters', async ({ browser }) => { // Filter by custom status code await clearTableFilters(page); await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection'); - await page.getByText('1 - 8 / 8').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.getByPlaceholder('Search').clear(); + await page.getByPlaceholder('Search').fill('002.01'); await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor(); await clearTableFilters(page); @@ -324,10 +329,20 @@ test('Stock - Stock Actions', async ({ browser }) => { 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 await page.getByText('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 .getByLabel('Stock Details') .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: 'Submit' }).click(); await page.getByText('Order issued').waitFor(); + await page.getByText('Issued', { exact: true }).first().waitFor(); await loadTab(page, 'Line Items'); diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index c86a490720..a812d42f05 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -1,9 +1,44 @@ /** Unit tests for form validation, rendering, etc */ import { expect, test } from 'playwright/test'; +import { createApi } from './api'; import { stevenuser } from './defaults'; import { navigate } from './helpers'; 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 }) => { const page = await doCachedLogin(browser, { user: stevenuser, diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index d42187ec85..a4bc753303 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -253,3 +253,60 @@ test('Plugins - Locate Item', async ({ browser }) => { await page.getByRole('button', { name: 'Submit' }).click(); 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